Compare commits

...
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 faf6ac09ec fix(profile): move the now-playing channel description with the bot (#159)
When the bot was moved to another channel, the channel it left kept the
now-playing description forever: updateChannelDescription always targeted
getChannelId(), which by then already reported the new channel.

Remember which channel we last wrote to. On a self clientMoved event,
clear that channel and, if a song is playing, write the description to
the new one. Stop now clears the channel we actually wrote to, so a
missed move event can't leave a stale description behind either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:36:57 +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
142 changed files with 35503 additions and 1023 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 /)"
]
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*",
"Bash(npx vitest:*)",
"Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)",
"Bash(gunzip -f /tmp/design.gz)",
"Read(//tmp/**)"
],
"deny": [
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
+2
View File
@@ -7,5 +7,7 @@ config.json
cookies/
.superpowers/
.worktrees/
.claude/
setup.log
/bin/
scripts/navbar_bigger.png
+392 -33
View File
@@ -5,41 +5,48 @@
<h1 align="center">TSMusicBot</h1>
<p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
</p>
<p align="center">
<img src="https://img.shields.io/badge/Node.js-20+-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/Node.js-20%20%7C%2022%20LTS-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/许可证-MIT-green" />
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/Jellyfin-可选-aa5cc3?logo=jellyfin&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p>
> v1.10.0 新增**可选**的 [Jellyfin](https://jellyfin.org/) 音源(由 [@ItsEricRao](https://github.com/ItsEricRao) 在 [PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123) 中贡献):连接自建 Jellyfin 服务器直接播放你自己的音乐库。默认关闭,在 **设置 → Jellyfin 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
- **本地音视频上传播放** — 在搜索页拖拽或选择本地文件上传,音频(mp3 / flac / wav / m4a / ogg / opus 等)和视频(mp4 / mov / avi / mkv / flv / wmv 等)都支持,视频上传后只保留其中的音轨;上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
@@ -55,20 +62,25 @@
### 方式一:Windows 一键部署(最简单)
只需电脑有网络连接,其他一切自动安装。
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
```
1. 下载或 clone 本项目
2. 双击 scripts\setup.bat (首次安装,自动安装 Node.js 和所有依赖)
3. 双击 scripts\start.bat (启动机器人)
4. 浏览器打开 http://localhost:3000
1. 安装 Node.js 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
2. 下载或 clone 本项目
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
4. 双击 scripts\start.bat (启动机器人)
5. 浏览器打开 http://localhost:3000
```
> `setup.bat` 会自动通过 winget 安装 Node.js(如果未安装),运行 `npm install` 安装所有依赖(包括内置 FFmpeg),最后构建项目。之后每次只需双击 `start.bat` 启动。
> **先装 Node.js 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
>
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
>
> **Node 20 已不再支持**:better-sqlite3 从 12.10.0 起不再发布它那个 ABI(115)的预编译包,装起来必须先备好 Python + C++ 构建工具([#152](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/152))。Node 24 及更新的大版本能用,但 @discordjs/opus 0.10.0 同样没有 Node 24(ABI 137)的预编译包,安装脚本会改用源码编译,需要构建工具且耗时更久——所以推荐 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
### 方式二:手动安装(所有系统)
**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
**前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
FFmpeg **已自动内置**,无需手动安装。
```bash
@@ -161,6 +173,16 @@ sudo ./scripts/install.sh
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### 关于 enabledProviders 音源开关(v1.10.0 起)
v1.10.0([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123))引入 `enabledProviders` 音源开关与可选的 Jellyfin 音源。当前默认值为 `["netease", "qq", "bilibili", "youtube", "kugou"]`——**与旧版行为一致**,从更早版本升级**无需任何操作**,在线音源照常可用;Jellyfin 需要手动开启(详见 [可选:Jellyfin 音源](#可选jellyfin-音源))。
> ⚠️ **仅影响短暂运行过 v1.10.0 初版的用户**:该版本曾把默认音源设为 Jellyfin-only。如果你在那段时间保存过设置,`data/config.json` 中可能被写入了 `"enabledProviders": ["jellyfin"]`,升级后在线音源会保持停用。修复方法:把在线音源加回列表(或直接删除该字段以使用默认值),重启机器人(网易云 / QQ 的内嵌 API 服务需要重启才会启动):
>
> ```json
> "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
> ```
### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
@@ -300,19 +322,21 @@ sudo systemctl start tsmusicbot
- 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
| **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
| **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -320,14 +344,17 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!play id <id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:<id>` 仍然可用 |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id <id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
@@ -335,19 +362,26 @@ sudo systemctl start tsmusicbot
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认网易云,自动续播) |
| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 |
| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) |
| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) |
| `!queues` | 列出已保存(共享)清单 |
| `!help` | 显示帮助信息 |
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
>
> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。
### TeamSpeak 命令权限(管理类命令限制)
@@ -372,6 +406,8 @@ sudo systemctl start tsmusicbot
### 音质等级
**在线音源(网易云等)**
| 等级 | 码率 | 格式 | 说明 |
|------|------|------|------|
| 标准 | 128kbps | MP3 | 免费可用 |
@@ -381,8 +417,29 @@ sudo systemctl start tsmusicbot
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
**Jellyfin(启用后)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。
### 重启后保留的播放设置
以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值:
| 设置 | 作用范围 | 存储位置 |
|------|----------|----------|
| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` |
| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` |
| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` |
聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。
## 项目架构
```
@@ -404,9 +461,11 @@ teamspeak-music-bot/
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
@@ -451,19 +510,79 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
| **运行时** | Node.js 20+, TypeScript 5 |
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
| **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api |
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
| **界面样式** | SCSS(YesPlayMusic 设计风格) |
| **图标** | @iconify/vue |
| **日志** | pino |
## 可选:Jellyfin 音源
本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
### 启用与连接配置
三种方式任选:
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
两种认证方式:
| 模式 | 填写内容 | 说明 |
|------|---------|------|
| **账号密码**(默认) | `username` + `password` | 以该用户身份登录(`AuthenticateByName`),token 自动持久化、失效自动重登 |
| **API Key** | `apiKey` + `userId` | 使用管理后台生成的 API Key;`userId` 决定使用谁的音乐库 / 收藏 / 歌单 |
```jsonc
// config.json 片段
{
"jellyfin": {
"serverUrl": "https://jellyfin.example.com",
"authMode": "userpass", // 或 "apikey"
"username": "music",
"password": "······",
"apiKey": "", // apikey 模式填写
"userId": "" // apikey 模式填写
},
"enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
}
```
> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
### 功能
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id <id>` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
### enabledProviders:音源开关
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
@@ -513,6 +632,131 @@ pip install -U yt-dlp
- 受 YouTube 风控/地域限制,部分视频可能无法播放
- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本
## Spotify 音源(实验性)
> **⚠️ 实验性功能,启用前请务必读完本节**
>
> - **需要 Spotify Premium 账号。** 免费账号无法通过 Spotify Connect 输出音频,无法使用本功能。
> - **使用你自己在 Spotify Developer Dashboard 注册的应用(Client ID)。** 本项目**不内置任何共享凭据**,也不会替你代管账号。
> - Spotify 官方并未开放第三方播放的公开授权,本功能处于 **Spotify 服务条款的灰色地带**,是否使用请自行评估,**风险自负**。
> - 该音源**默认关闭**(`spotify.enabled = false`),需要手动开启并完成授权。
> - 这**不是** YouTube 那样的「免登录回退音源」,而是**真正的 Spotify 音频**,必须有 Premium 才能出声;音频链路依赖第三方开源解码器(librespot / go-librespot),本项目仅在本地作为独立子进程调用,**播放效果不做保证**,也未在本仓库端到端测试。
### 工作原理(简述)
1. `librespot`(Rust)或 `go-librespot`(Linux)作为**独立子进程**登录 Spotify Connect 并解码音频,输出原始 **PCM**。
2. 本项目用内置 **ffmpeg** 把 PCM 重采样到 **48kHz**。
3. 重采样后的音频接入**现有的 Opus 编码 / 发送管线**(与其它音源共用),推送到 TeamSpeak。
4. 歌名、歌手、封面等**元数据来自 Spotify Web API**。
### 平台矩阵
后端由配置项 `spotify.backend` 决定,可选 `auto`(默认)/ `go-librespot` / `librespot`:
| 平台 | 默认后端(`auto`) | 说明 |
|------|------|------|
| Windows | `librespot`(Rust) | 不支持 go-librespot(FIFO 仅限 POSIX,且官方无 Windows 资产) |
| Linux / Docker | `go-librespot`(可回退 `librespot`) | `auto` 优先 go-librespot,未检测到时自动改用 librespot |
| macOS | `librespot`(Rust) | 与 Windows 同,仅支持 Rust 版 |
> `auto` 会按平台与二进制可用性自动选择:优先 `go-librespot`(若可用),否则 `librespot`。若显式指定 `go-librespot` 或 `librespot` 但对应二进制不存在,则该音源保持不可用。
### 获取二进制
程序会先在项目根目录的 `bin/` 中查找,找不到再回退到系统 `PATH`。`bin/` 已被 `.gitignore` 忽略,不影响代码更新。
**Rust librespot(librespot-org,全平台)** — 官方**没有预编译发布包**,需自行获取(任选其一):
```bash
# 方式 1:用 Cargo 编译安装(需要 Rust 工具链)
cargo install librespot
# 方式 2(Windows):scoop / choco
scoop install librespot # 或:choco install librespot
# 方式 3:把可执行文件放到项目 bin/ 目录
# Windows: bin/librespot.exe
# Linux/macOS: bin/librespot
```
或直接把 `librespot` 加入系统 `PATH`。
**go-librespot(仅 Linux)** — 官方仅提供 **Linux** 预编译资产:
```bash
# 从 Release 页下载对应架构的二进制:
# https://github.com/devgianlu/go-librespot/releases
# 放到项目 bin/ 目录(或加入系统 PATH):
# bin/go-librespot
```
> **Windows 不支持 go-librespot**:它依赖 POSIX FIFO(`mkfifo`),官方也只发布 Linux 资产。Windows / macOS 请使用 Rust `librespot`。
### 注册 Spotify 开发者应用 + 回调地址
1. 打开 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard),新建一个应用,记下 **Client ID**。
2. 在应用设置里添加 **Redirect URI(回调地址)**,精确填写:
```
http://127.0.0.1:<webPort>/api/spotify/callback
```
其中 `<webPort>` 与本项目设置里的 Web 端口一致(默认 `3000`,即 `http://127.0.0.1:3000/api/spotify/callback`)。回调路径必须精确为 `/api/spotify/callback`。
3. 本项目使用 **Authorization Code + PKCE** 流程,**不需要 Client Secret**(配置里的 `clientSecret` 可留空)。
4. 授权时请求的权限范围(scope):
```
streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private
```
### 启用步骤
1. 进入**设置页**的「连接 Spotify」卡片。
2. 填入 **Client ID**、选择**后端**(`auto` / `go-librespot` / `librespot`)、打开**开关**。
3. 点击**保存**。
4. 点击「**连接 Spotify**」,在弹出的 Spotify 页面完成 **OAuth 授权**。
> 仅当 **`enabled = true`** + **已完成 OAuth 授权** + **检测到可用的后端二进制** 三者同时满足时,Spotify 音源才可播放。任一条件不满足,该音源保持**不可用**(点播会被跳过,播放队列照常前进,不影响其它音源)。
对应的配置块(`data/config.json`):
```json
{
"spotify": {
"enabled": false,
"backend": "auto",
"clientId": "",
"clientSecret": "",
"deviceName": "TSMusicBot",
"bitrate": 320
}
}
```
OAuth 相关端点:`/api/spotify/login`、`/api/spotify/callback`、`/api/spotify/status`。
### 许可与来源
- **go-librespot** 采用 **GPL-3.0** 许可。本项目**仅将其作为独立子进程调用**(mere aggregation / 独立聚合),**不链接、不打包**其代码,因此**不影响本项目自身的 MIT 许可**。
- 源码与许可:<https://github.com/devgianlu/go-librespot>(GPL-3.0;如需其对应源码请前往该仓库获取 —— source offer)。
- **Rust librespot** 采用 **MIT** 许可:<https://github.com/librespot-org/librespot>。
### 故障排查
| 现象 | 处理 |
|------|------|
| 提示「未检测到 librespot / go-librespot」 | 检查项目 `bin/` 目录里是否放了可执行文件,或该命令是否在系统 `PATH` 中;Rust 版可用 `cargo install librespot` 安装 |
| 提示「未授权 / 需要连接 Spotify」 | 在设置页「连接 Spotify」卡片点击「连接 Spotify」完成 OAuth 授权 |
| Windows 上无法使用 go-librespot | 属预期行为(FIFO 仅限 POSIX,官方无 Windows 资产);请把 `backend` 设为 `librespot` 或 `auto` |
| 完全没有声音 | 确认账号为 **Spotify Premium**;免费账号无法通过 Spotify Connect 输出音频 |
### 已知限制
- 在多租户/共享主机上,librespot 通过命令行参数接收访问令牌,同机其他本地进程理论上可读取(令牌约 1 小时有效,需本地访问权限)。
- Spotify 连续播放(gapless spotify→spotify)时,网页进度条的"已播放时间"可能不准确(以后端上报的播放进度为准)。
- 运行多个启用 Spotify 的 bot 时,若两个 bot 的 id 端口哈希发生冲突(同一 % 1000 桶),第二个 go-librespot 边车会因端口占用而启动失败、该 bot 的 Spotify 不可用(后续将改为按需分配空闲端口)。
- **一个 Spotify Premium 账号只支持「一路」正在播放的音频流**。因此若要同时运行**多个**启用 Spotify 的 bot 并让它们各自独立播放,必须为**每个 bot 配置独立的 Spotify 账号**。在 Rust(librespot)后端上,本机制已把播放控制(暂停/继续/跳转)限定到 bot 自己的设备,并在读取播放状态时忽略其它设备的状态,以避免多 bot 之间互相抢占、来回抖动(cross-control/thrash);但受 Spotify 平台限制,**共用同一账号无法实现多路同时播放**(第二个 bot 开始播放会夺走该账号唯一的活跃会话)。go-librespot 后端为每个边车独立的本地 REST API,不受此账号级抢占影响。
## 配置文件
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
@@ -558,11 +802,14 @@ A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
**Q:启动报 `NODE_MODULE_VERSION 137 ... requires 127`,或提示找不到 `opus.node`?**
A:换过 Node 大版本了。原生模块(`@discordjs/opus`、`better-sqlite3`)编译时绑定了一个 Node ABI(Node 20 = 115、22 = 127、24 = 137),换版本后旧的 `.node` 就再也加载不了。**重新运行一次 `scripts\setup.bat`(Linux/macOS 是 `bash scripts/setup.sh`)即可**——安装脚本会实际加载一遍每个原生模块,发现和当前 Node 不匹配就自动重新下载/编译,替换过程中失败也会把原来的文件还原回去。`start.bat` 和 `npm start` 在启动前也会先做这个检查,直接告诉你哪个模块对不上、分别是哪个 ABI,而不是抛一串看不懂的堆栈。想彻底重来就删掉 `node_modules` 和 `web\node_modules` 再跑一次 `setup.bat`。
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id <歌曲id>` 按 id 精确播放(`!search` 结果里每行末尾的 `[id:...]` 就是它);(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
**Q:如何更换机器人所在频道?**
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -571,7 +818,16 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
**Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。
**Q:播放歌曲时报 FFmpeg EACCES 错误?**
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
@@ -628,9 +884,104 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
**本地视频上传播放([#149](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/149),[PR #151](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/151),感谢 [@LadenceE](https://github.com/LadenceE))**
- 搜索页的本地上传现在也收**视频文件**:mp4 / mov / avi / mkv / flv / wmv / m4v / mpg / mpeg / 3gp / ts / m2ts / ogv。上传后当普通歌曲用——直接播放、下一首播放、加入队列都一样。
- 视频**只保留音轨**:上传后立刻把音频流原样搬进一个音频容器(不重编码、无损),再删掉原视频。720p 素材实测落盘只剩原文件的 14%,不然十几个视频就把 5 GiB 的上传目录配额占满了。
- 没有音轨的视频会在**上传时**就被拒绝并说明原因,而不是排进队列后静默跳过。
- 单文件上限从 200 MB 提到 **500 MB**;超限时的报错从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成正常的中文提示,浏览器端也会在开传前就拦下超大文件。
- 上传进度按文件显示百分比,传完切到「服务端处理中」——视频比音频大得多,原先那句静止的「正在上传」看着像卡死。
- 说明:这里做的是「把你本地磁盘上的文件传上来播放」。让机器人直接读取**服务器**磁盘上任意路径的文件没有做——那等于开一个全盘任意文件读取的口子,而「播放服务器上已有的媒体库」用 Jellyfin 音源即可。
**初始化阶段不再发起注定失败的头像上传([#148](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/148),[PR #150](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/150),感谢 [@shenmu-rua](https://github.com/shenmu-rua))**
- 机器人构造阶段读出已保存的自定义头像后会立刻发起文件传输,但那时 TeamSpeak 还没连上,这次传输必定失败。现在构造阶段只把头像数据装入内存,实际上传交给连接成功后的 `onConnect()`。
- **影响范围说明**:头像本身一直是能正常显示的(连接成功后本来就会重新应用一次),所以这不是「头像丢了」。真正的代价是每次启动 / 重启都会多一次注定失败的请求和一条 `Profile update failed` 警告日志——现在没有了。
- 顺带修掉一个边角:头像文件写到一半崩溃会留下 0 字节文件,原先这会再触发两个同样注定失败的请求。
### v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈
一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。
**安装脚本按 Node ABI 校验并自动修复([#140](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/140),[PR #147](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/147),感谢 [@zbn297427669](https://github.com/zbn297427669))**
- 换过 Node 大版本后启动报 `NODE_MODULE_VERSION 137 ... requires 127`、或提示找不到 `opus.node` 的问题已修复。原生模块只能在编译它的 Node ABI 上加载,而旧脚本只检查「文件存在且够大」,会把给另一个 Node 版本编译的二进制原样留下。
- 现在安装脚本会在子进程里真的加载一遍每个原生模块,不匹配就按当前 ABI 重新安装;替换过程先备份再原子替换,任何一步失败都会把原文件逐字节还原,不会让环境变得更糟。被中断留下的备份,下次运行自动认领回来。
- 必需模块失败会**中止安装并返回非零**,不再出现「setup 显示成功、start 才爆炸」;下载进度实时显示在控制台,不再让人以为卡死。
- `start.bat` 和 `npm start` 启动前会预检,直接说清楚哪个模块对不上、分别是哪个 ABI、怎么修。
- 顺带修掉一个会**静默丢掉 ffmpeg** 的问题:源码编译会阻塞事件循环,把同时进行的 80MB ffmpeg 下载误判为超时,而 ffmpeg 是可选模块,于是安装照样报告成功、用户却放不出任何声音。三个模块改为串行处理。
- Node 版本要求按依赖真实下限判断(20.19+ / 22.12+),推荐 20 或 22 LTS;更新的大版本不阻止,只提示可能需要源码编译。
**随机模式下 `!pn` 真正下一首播放([#141](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/141),[PR #144](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/144),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
- 随机 / 随机循环下 `!pn`(以及 WebUI 的「下一首播放」)插入的歌只是和其他歌一样等着被随机抽中,机器人却回复「Up next」。现在会真的下一首播放,连续插入多首时的顺序与队列里显示的一致。
- 同时修掉两个相关问题:插入或删除队列中的歌之后,待播位置可能指向另一首歌;删得多了甚至会让播放**静默停止**。
**WebUI 站点图标与移动端交互([#142](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/142) / [#143](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/143) / [#138](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/138),[PR #146](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/146),感谢 [@XuVIIJay](https://github.com/XuVIIJay) 与 [@hak5ya](https://github.com/hak5ya))**
- 新增站点图标:收藏网页、移动端添加到主屏幕都会显示图标(含 iOS 与 Android 适配)。
- 移动端迷你播放器的进度条现在**可以点按和拖动调节进度**,触摸区域也放大到可用尺寸,拖动时不会被自动跳转到歌词页。
- 移动端**单击歌曲行即可播放**(桌面端双击行为不变);队列抽屉里的歌曲行同样支持,其移除按钮在触屏下不再是「看不见但点得到」。
- QQ 扫码登录的提示改为「请使用手机QQ扫码」——那是 QQ 账号二维码,用 QQ音乐 APP 扫不出来。
**`!play id <id>` 与其他命令语法统一([#139](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/139),[PR #145](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/145),感谢 [@hak5ya](https://github.com/hak5ya))**
- 按 id 播放现在可以写成 `!play id <id>`,和其他命令的 `<命令> <子命令> <参数>` 形式一致;`!add` / `!playnext` 同样适用。
- **旧写法 `!play id:<id>` 继续支持**。空格写法只在参数确实像 id 时生效,普通搜索和粘贴链接的行为不受影响。
### v1.11.2 — 可配置语音闪避
- 检测频道内其他人说话时平滑降低音乐音量,停止后平滑恢复;默认关闭,可在设置页启用并调节说话时保留的音量比例([#136](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/136),[PR #137](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/137))。
### v1.11.1:修复 `!help` 触发机器人自动点歌
**丢弃自回显消息([PR #135](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/135),感谢 [@EvolvedGhost](https://github.com/EvolvedGhost))**
- 修复输入 `!help` 后机器人会自己点一首歌开始播放的问题:帮助文本超过 TeamSpeak 单条消息上限被分段发送,而 TeamSpeak 会把 bot 自己发到频道的消息回推给它自己,第二段恰好以 `!artist ...` 开头,被误当作新命令解析执行。
- 现在在协议层丢弃发送者为机器人自身的消息,机器人不再响应任何自己发出的文本,所有超长分段输出均安全。无配置变化,升级无需任何操作。
### v1.11.0 — 播放清单持久化 / 设置保留 / 自定义默认音源
**保存/加载播放清单 + 队列持久化([#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119))——三项开关均默认关闭,升级无行为变化**
- **保存/加载播放清单(`savedQueuesEnabled`,默认关闭,管理员开关)**:开启后,可在网页「已存队列」页或聊天命令保存当前队列为清单、随时**替换**加载或**追加**到队列末尾。网页保存可选「共享」(否则私有到当前用户);聊天命令始终进入共享清单。新增聊天命令 `!save <名称>` / `!load [-a] <名称>` / `!queues`(未启用时回复「此功能未启用」)。上限:每个所有者 ≤ 50 份清单,每份 ≤ 1000 首。
- **重启后自动恢复并继续播放队列**(同由 `savedQueuesEnabled` 门控):机器人连接后会恢复上次的队列并继续播放。**说明**:只能从当前曲目的**开头**恢复(不记忆播放进度,链接重新解析);**Spotify 恢复为尽力而为**(依赖 sidecar 重新可用),其他音源可靠。
- **单曲直接播放不清空队列(`playKeepsQueue`,默认关闭,独立开关)**:开启后,直接播放单曲会插入到当前歌曲之后并立即播放、播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单 / 专辑 / 电台仍会替换队列。
- 三项均在 设置 → 行为设置 中开关,保存即时生效,无需重启。
**重启后保留播放设置([#125](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/125))**
- **音量与播放模式**现按机器人持久化到数据库(`bot_instances` 表新增 `volume` / `play_mode` 列,自动迁移),**各平台音质**持久化到 `config.json` 的 `audioQuality` 字段;重启后自动恢复,不再需要每次手动重调。
- 聊天命令、WebUI、REST 三种入口的改动都会落盘;`!fm` / `!artist` 的临时随机 / 循环切换**不会**覆盖你用 `!mode` 显式保存的偏好。播放队列、当前歌曲与播放进度仍不持久化(队列恢复见上方 `savedQueuesEnabled`)。
**自定义默认音源([#126](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/126))**
- `config.json` 新增可选字段 `defaultPlatform`,或在 **设置 → 默认音源** 下拉选择:设定后,不带平台标志的 `!play` / `!search` / `!fm` 走你指定的音源(例如设为 `bilibili` 后点播 B 站视频音乐无需每次加 `-b`)。
- 留空 / `null` 恢复原有固定优先级(网易云 → QQ → 酷狗 → Jellyfin → B 站 → YouTube);若指定音源未启用或值非法,自动回退到优先级,保存即时生效、无需重启。
**修复与加固**
- **QQ 音乐 API 端口对齐([#122](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/122))**:内嵌 QQ 音乐 API sidecar 现在保证绑定到 `qqMusicApiPort`(与客户端请求端口一致),启动日志改为打印**实际绑定端口**便于排查。若你在旧 `latest` 镜像上遇到「日志里 baseURL 是 3200、服务却在 3300」导致二维码不显示,请 `docker compose pull` 重新拉取镜像。
- **WebUI 不再被搜索引擎收录([#128](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/128))**:所有响应加 `X-Robots-Tag: noindex, nofollow`、新增 `/robots.txt`(Disallow 全站)、页面加 `robots` meta 标签。⚠️ 这只是阻止**收录**,不是访问控制——公网部署请务必依赖登录鉴权与反向代理,并且不要把自己的 WebUI 链接发到公开网页。
- **`.gitignore` 补充 `.claude/`([#127](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/127),感谢 [@ItsEricRao](https://github.com/ItsEricRao))**:本地 Claude Code 配置不再被误提交(已从版本库取消跟踪,本地文件不受影响)。
### v1.10.1 — Jellyfin 可选音源
**Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
- **Jellyfin 音源(可选,默认关闭)**:连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源——搜索(歌曲 / 专辑 / 歌单,支持翻页)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」区块、播放进度回报(PlayCount / 播放状态)。账号密码或 API Key 两种认证,在 设置 → Jellyfin 音乐库 打开「启用 Jellyfin 音源」即可,保存即时生效。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)。
- **enabledProviders 音源开关**:`config.json` 新增 `enabledProviders` 字段,默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`——在线音源保持默认启用,**从旧版本升级无行为变化**;列表外的音源在聊天命令 / REST / WebUI 中一律不可用,网易云 / QQ 停用时其内嵌 API 服务(端口 3001 / 3200)不再启动。
- **新增 `-j`(Jellyfin)与 `-n`(网易云)平台标志**;不带标志的 `!play` / `!search` / `!fm` 等走固定优先级中第一个已启用的音源(默认配置下即网易云,行为与旧版一致)。
- ⚠️ **v1.10.0 初版曾短暂把默认音源设为 Jellyfin-only,现已回退**。若你在该版本保存过设置导致 `config.json` 中为 `"enabledProviders": ["jellyfin"]`,请手动把在线音源加回(详见 [更新升级](#关于-enabledproviders-音源开关v1100-起))。
- **QQ 按 ID 播放空歌名修复**([PR #124](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/124),感谢 [@Slldyd2077](https://github.com/Slldyd2077)):按 ID 播放 QQ 歌曲时回填歌曲元数据,TS 端不再显示空歌名。
### v1.9.0 及更早
**功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
@@ -638,9 +989,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
- **#112 Spotify 音源(实验性)**:新增 Spotify 作为可选音源,默认关闭、需 Premium + 自建开发者应用(PKCE,无需 Client Secret)。采用混合 librespot 后端(Linux/Docker 用 go-librespot,Windows 用 Rust librespot),元数据走 Spotify Web API,可与现有音源混排入队。为 ToS 灰色地带的实验特性,详见 [Spotify 音源(实验性)](#spotify-音源实验性)。
- **#115 搜索结果翻页**:WebUI 搜索现按来源、按分类(歌曲 / 歌单 / 专辑)提供「加载更多」,服务端新增 `offset` 分页,不再固定只返回首页 20 首 / 10 个歌单 / 10 张专辑。
**Bug 修复**
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
@@ -655,6 +1009,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **搜索引擎隐身(防止实例被收录,issue #128)**:为避免部署实例的 WebUI 被搜索引擎收录、被陌生人搜到控制页,采用纵深防御——所有响应携带 `X-Robots-Tag: noindex, nofollow`,`/robots.txt` 返回 `User-agent: * / Disallow: /`,`index.html` 内置 `<meta name="robots" content="noindex, nofollow">`(专属链接 `/bot/<id>` 等所有页面同样覆盖)。这些只阻止「被索引」,不是访问控制——**请不要把自己的 WebUI 链接发到公开网页 / 论坛 / 聊天群**,真正的防护来自登录鉴权与反向代理。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级
@@ -727,6 +1082,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢
@@ -735,6 +1091,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| 项目 | 说明 |
|------|------|
| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
| [ItsEricRao](https://github.com/ItsEricRao) | Jellyfin 音源集成贡献者([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123)) |
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
@@ -747,6 +1105,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
## 开源许可
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,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).
+342 -352
View File
File diff suppressed because it is too large. Load diff
+15 -2
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",
@@ -17,7 +22,7 @@
"@discordjs/opus": "^0.10.0",
"@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"@sansenjian/qq-music-api": "~2.4.0",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -28,7 +33,7 @@
"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",
@@ -47,5 +52,13 @@
"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;
}
+9 -2
View File
@@ -4,7 +4,7 @@
# ==========================================
# --- Stage 1: Build backend + frontend ---
FROM node:20-slim AS builder
FROM node:22-slim AS builder
# Install build tools for native modules (opus, better-sqlite3)
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -30,7 +30,7 @@ RUN npm run build
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# --- Stage 2: Production image ---
FROM node:20-slim
FROM node:22-slim
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
@@ -46,6 +46,13 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# package.json declares a `prestart` preflight, so `npm start` inside the
# container needs this file. The image's own CMD calls node directly and never
# goes through npm, but an interactive `docker exec ... npm start` would
# otherwise die on a missing script rather than starting the bot.
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
# ...and the module it imports for crash-proof logging.
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
+687 -97
View File
@@ -1,161 +1,751 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
* Verify / download / repair the native binaries used by TSMusicBot
* (ffmpeg-static + @discordjs/opus + better-sqlite3), preferring the
* npmmirror CDN so China users never have to reach GitHub.
*
* Called by setup.bat / setup.sh after `npm install --ignore-scripts`.
*
* WHY THIS IS NOT JUST A DOWNLOADER
* ---------------------------------
* A compiled addon only loads into the exact Node ABI it was built for
* (process.versions.modules: Node 20 = 115, Node 22 = 127, Node 24 = 137).
* better-sqlite3 stores its addon at an ABI-agnostic path
* (build/Release/better_sqlite3.node), so a "file exists and is big enough"
* check happily keeps a binary built for a *different* Node major around and
* the bot then dies with `NODE_MODULE_VERSION 137 ... requires 127`.
* So we validate by actually LOADING each package — in a short-lived child
* process, because on Windows a loaded .node stays mapped and the OS then
* refuses to delete or overwrite it.
*
* Every repair is staged and swapped in atomically: if a download fails we put
* the previous file back, so a failed run can never leave the install in a
* worse state than it started.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
* Env: TSMB_BINARY_LOG_STDOUT=1 also echo progress to stdout
* (setup.bat uses this to show progress live on stderr while stdout
* is redirected into setup.log)
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import {
chmodSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { basename, dirname, join } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createLineWriter } from "./lib/console-log.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules");
const BACKUP_DIR = join(NODE_MODULES, ".tsmusicbot-backup");
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
/** The newest Node major this project is regularly tested against, and the one
* every required addon currently ships a prebuild for. Keep in sync with
* TESTED_NODE_MAJOR in scripts/setup.bat. */
const TESTED_NODE_MAJOR = 22;
function download(url) {
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
const REQUIRED = new Set(["@discordjs/opus", "better-sqlite3"]);
/** ffmpeg-static ships ~40-90 MB depending on platform; anything under this is
* certainly a truncated download, not a real build. */
const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
// ---------------------------------------------------------------------------
// logging
// ---------------------------------------------------------------------------
// Progress goes to stderr so setup.bat can show it live while stdout is being
// appended to setup.log. TSMB_BINARY_LOG_STDOUT=1 mirrors it into stdout so the
// log keeps the full transcript too.
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
// Both writers swallow a failed write instead of letting it become an uncaught
// 'error' event: a console that cannot print the Chinese half of a line (issue
// #152) must not be able to abort a whole setup run. The two streams degrade
// independently, so setup.log keeps the full bilingual transcript either way.
const writeErr = createLineWriter(process.stderr);
const writeOut = createLineWriter(process.stdout);
function log(msg) {
const line = msg === "" ? "" : ` [binary] ${msg}`;
writeErr(line);
if (ECHO_STDOUT) writeOut(line);
}
// ---------------------------------------------------------------------------
// small helpers
// ---------------------------------------------------------------------------
function sizeOf(filePath) {
try {
return statSync(filePath).size;
} catch {
return 0;
}
}
function humanSize(filePath) {
const bytes = sizeOf(filePath);
if (!bytes) return "unknown size";
return bytes >= 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${(bytes / 1024).toFixed(0)} KB`;
}
function ensureExecutable(filePath) {
if (PLATFORM === "win32") return;
try {
chmodSync(filePath, 0o755);
} catch {
/* best effort */
}
}
/** Read the version actually present in node_modules (never hardcode it: the
* lockfile can be far ahead of whatever version this script was written for,
* and a wrong version means a 404 on the CDN). */
function readInstalledVersion(spec) {
try {
const pkgJson = join(NODE_MODULES, ...spec.split("/"), "package.json");
const version = JSON.parse(readFileSync(pkgJson, "utf8")).version;
return typeof version === "string" && version ? version : null;
} catch {
return null;
}
}
function summarizeError(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
return (interesting || lines[0] || "unknown error").slice(0, 300);
}
// ---------------------------------------------------------------------------
// download
// ---------------------------------------------------------------------------
function download(url, redirects = 0) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirects >= 5) {
reject(new Error(`too many redirects: ${url}`));
return;
}
resolve(download(new URL(headers.location, url).toString(), redirects + 1));
return;
}
if (statusCode < 200 || statusCode >= 300) {
res.resume();
reject(new Error(`HTTP ${statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("error", reject);
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
req.on("timeout", () => {
req.destroy();
reject(new Error(`timeout: ${url}`));
});
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
let tarModule = null;
/** `tar` is not a declared dependency — it only resolves transitively through
* prebuild-install / @discordjs/node-pre-gyp. Fail with a sentence a user can
* act on instead of a raw MODULE_NOT_FOUND stack. */
function loadTar() {
if (tarModule) return tarModule;
try {
tarModule = createRequire(import.meta.url)("tar");
} catch {
throw new Error(
"'tar' module not available / 找不到 tar 模块 — run `npm install tar` in the project root and retry",
);
}
return tarModule;
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
async function extractTarGz(buf, cwd) {
const tar = loadTar();
const tmpFile = join(tmpdir(), `tsmb-${process.pid}-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
try {
await tar.extract({ cwd, file: tmpFile });
} finally {
try {
rmSync(tmpFile, { force: true });
} catch {
/* ignore */
}
}
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
// ---------------------------------------------------------------------------
// load probe (the whole point of this rewrite)
// ---------------------------------------------------------------------------
/**
* The snippet that actually forces each package's addon to be dlopen()ed.
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor
* (lib/database.js: `DEFAULT_ADDON || (DEFAULT_ADDON = require('bindings')(...))`),
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI binary.
* Opening an in-memory database is the cheapest way to really load it.
*/
const PROBE_EXPR = {
"@discordjs/opus": "require('@discordjs/opus')",
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
};
/**
* Try to load a package in a throwaway child process.
* Child process on purpose: loading an addon here would keep the DLL mapped and
* Windows would then refuse to rename/delete the file we are about to replace.
*/
function probeRequire(spec) {
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
try {
execFileSync(process.execPath, ["-e", expr], {
cwd: ROOT,
stdio: "pipe",
timeout: 120000,
windowsHide: true,
});
return { ok: true };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
// requires NODE_MODULE_VERSION 127..." -> first number is what it was built for.
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
return {
ok: false,
abiMismatch: abis.length >= 2,
compiledAbi: abis.length >= 2 ? abis[0] : null,
error: summarizeError(text),
};
}
}
function describeProbe(probe) {
if (probe.abiMismatch) {
return `built for Node ABI ${probe.compiledAbi}, but this Node needs ABI ${NODE_ABI}`;
}
return probe.error;
}
function probeFfmpegBinary(bin) {
try {
const out = execFileSync(bin, ["-version"], {
stdio: "pipe",
timeout: 30000,
windowsHide: true,
}).toString();
return { ok: true, version: (out.split(/\r?\n/)[0] || "").slice(0, 60) };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
return { ok: false, error: summarizeError(text) };
}
}
// ---------------------------------------------------------------------------
// atomic swap helpers
// ---------------------------------------------------------------------------
let stashCounter = 0;
/**
* Move `target` (file or directory) out of the way into node_modules/.tsmusicbot-backup.
* Same volume as node_modules, so the rename is atomic, and outside the package's
* build/ tree so that `npm rebuild` / `node-gyp clean` cannot wipe the backup.
* Returns { commit, restore } — call exactly one of them.
*/
/** Windows likes to hold a brief lock on a freshly written .node (antivirus,
* indexer), and rmSync does not retry by default. */
const RM_OPTS = { recursive: true, force: true, maxRetries: 5, retryDelay: 150 };
function stash(target) {
if (!existsSync(target)) {
return { commit() {}, restore() {} };
}
mkdirSync(BACKUP_DIR, { recursive: true });
const backup = join(BACKUP_DIR, `${basename(target)}.${process.pid}.${stashCounter++}.bak`);
rmSync(backup, RM_OPTS);
renameSync(target, backup);
// The backup filename alone cannot say where the artifact came from, and a
// run that is killed (Ctrl+C during a slow download) never reaches commit or
// restore. Record the target so the next run can put it back — see
// recoverOrphanedBackups().
const manifest = `${backup}.json`;
try {
writeFileSync(manifest, `${JSON.stringify({ target })}\n`);
} catch {
/* recovery is best-effort; the swap itself still works */
}
let settled = false;
const dropManifest = () => {
try {
rmSync(manifest, RM_OPTS);
} catch {
/* ignore */
}
};
return {
commit() {
if (settled) return;
settled = true;
try {
rmSync(backup, RM_OPTS);
} catch {
/* leftover backup is harmless */
}
dropManifest();
},
restore() {
if (settled) return;
settled = true;
try {
rmSync(target, RM_OPTS);
mkdirSync(dirname(target), { recursive: true });
renameSync(backup, target);
dropManifest();
log(`restored the previous ${basename(target)} — nothing was made worse`);
} catch (err) {
// Leave the backup AND its manifest in place: recoverOrphanedBackups()
// on the next run is the second chance.
log(`WARN: could not restore ${target} from ${backup}: ${err.message}`);
log(`WARN: the previous file is still at ${backup} — the next run will try again`);
}
},
};
}
/**
* Put back anything a previous run stashed but never restored — a run killed
* mid-download, or one whose restore() itself failed. Only acts when the target
* is currently absent, so it can never clobber a good binary.
*/
function recoverOrphanedBackups() {
if (!existsSync(BACKUP_DIR)) return;
let entries;
try {
entries = readdirSync(BACKUP_DIR);
} catch {
return;
}
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const manifest = join(BACKUP_DIR, entry);
const backup = manifest.slice(0, -".json".length);
try {
const { target } = JSON.parse(readFileSync(manifest, "utf8"));
if (!target || !existsSync(backup)) {
rmSync(manifest, RM_OPTS);
continue;
}
if (existsSync(target)) continue; // a good file is already there — leave it alone
mkdirSync(dirname(target), { recursive: true });
renameSync(backup, target);
rmSync(manifest, RM_OPTS);
log(`recovered ${basename(target)} left behind by an interrupted run`);
} catch (err) {
log(`WARN: could not process leftover backup ${entry}: ${err.message}`);
}
}
}
function cleanupBackupDir() {
try {
if (existsSync(BACKUP_DIR) && readdirSync(BACKUP_DIR).length === 0) {
rmSync(BACKUP_DIR, { recursive: true, force: true });
}
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// per-module results
// ---------------------------------------------------------------------------
/** status: "ok" | "repaired" | "failed" | "missing" */
function makeResult(name, status, detail) {
return { name, required: REQUIRED.has(name), status, detail };
}
function buildFromSource(command) {
// stdout -> inherited (setup.bat sends it to the log), stderr -> inherited so
// compiler progress stays visible; npm's own output is far too noisy to buffer.
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
}
/**
* A 404 from the CDN is not a mirror outage: it means this exact package
* version publishes no prebuilt binary for the running Node ABI at all.
* @discordjs/opus 0.10.0 has no build for Node 24 (ABI 137), so a user on that
* major lands in the source-build fallback below and is told to install Python
* and a C++ toolchain. Switching Node major is the far cheaper fix, and nothing
* else in this output points at it. (better-sqlite3 dropping its Node 20 / ABI
* 115 builds in 12.10.0 is why Node 20 is no longer accepted at all.)
* See issue #152.
*/
function explainMissingPrebuild(name, version, err) {
if (!/HTTP 404/.test(err.message)) return;
log(`${name}: ${name}@${version} ships no prebuilt binary for Node ${NODE_MAJOR} (ABI ${NODE_ABI})`);
log(
`${name}: Node ${TESTED_NODE_MAJOR} LTS has one — switching Node is usually much quicker than ` +
`setting up a compiler (换用 Node ${TESTED_NODE_MAJOR} LTS 通常比装编译环境快得多)`,
);
}
function buildToolsHint() {
log("Install build tools first:");
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
log(" Ubuntu/Debian: sudo apt install build-essential python3");
log(" CentOS/RHEL: sudo yum groupinstall 'Development Tools'");
}
// ---------------------------------------------------------------------------
// ffmpeg-static (OPTIONAL — a system ffmpeg is a documented fallback)
// ---------------------------------------------------------------------------
async function ensureFfmpeg() {
const name = "ffmpeg-static";
const ffDir = join(NODE_MODULES, name);
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
if (!existsSync(ffDir)) {
log(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`);
return makeResult(name, "missing", "package not installed");
}
if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) {
ensureExecutable(ffDest);
const probe = probeFfmpegBinary(ffDest);
if (probe.ok) {
log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`);
return makeResult(name, "ok", humanSize(ffDest));
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
// Deliberately NOT re-downloading here: ffmpeg is a plain executable with no
// ABI to mismatch, and forcing an ~80 MB re-download because `-version`
// could not be spawned would hurt exactly the slow-network users this
// script exists for.
log(`${name}: present (${humanSize(ffDest)}) but could not be executed: ${probe.error}`);
return makeResult(name, "ok", "present, not verified");
}
if (existsSync(ffDest)) {
log(`${name}: existing ffmpeg looks truncated (${humanSize(ffDest)}), re-downloading...`);
} else {
log(`${name}: ffmpeg binary missing, downloading...`);
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
const backup = stash(ffDest);
const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`;
try {
log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`);
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(tmpDest));
ensureExecutable(tmpDest);
if (sizeOf(tmpDest) < FFMPEG_MIN_BYTES) {
throw new Error(`downloaded ffmpeg is only ${humanSize(tmpDest)} — truncated`);
}
renameSync(tmpDest, ffDest); // atomic swap, same directory
backup.commit();
log(`${name}: OK (${humanSize(ffDest)})`);
return makeResult(name, "repaired", humanSize(ffDest));
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
rmSync(tmpDest, { force: true });
} catch {
/* ignore */
}
backup.restore();
log(`${name}: download failed — ${err.message}`);
log(`${name}: not fatal — install ffmpeg system-wide and put it on PATH instead`);
return makeResult(name, "failed", err.message);
}
}
// ---------------------------------------------------------------------------
// @discordjs/opus (REQUIRED)
// ---------------------------------------------------------------------------
async function ensureOpus() {
const name = "@discordjs/opus";
const pkgDir = join(NODE_MODULES, "@discordjs", "opus");
const prebuildRoot = join(pkgDir, "prebuild");
// node-pre-gyp resolves this directory from the *running* Node's ABI, so a
// stale build for another ABI simply sits at another path and is ignored.
const prebuildDirName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const destDir = join(prebuildRoot, prebuildDirName);
if (!existsSync(pkgDir)) {
log(`${name}: package not installed — run 'npm install' first`);
return makeResult(name, "missing", "package not installed");
}
const before = probeRequire(name);
if (before.ok) {
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
}
log(`${name}: unusable — ${describeProbe(before)}`);
log(`${name}: installing a build for ABI ${NODE_ABI}...`);
const version = readInstalledVersion(name) || "0.10.0";
const url =
`${CDN}/@discordjs/opus/v${version}/opus-v${version}` +
`-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
const backup = stash(destDir);
let staging = null;
try {
try {
log(`${name}: GET ${url}`);
const buf = await download(url);
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
await extractTarGz(buf, staging);
const staged = join(staging, prebuildDirName);
if (!existsSync(join(staged, "opus.node"))) {
throw new Error(`tarball did not contain ${prebuildDirName}/opus.node`);
}
mkdirSync(prebuildRoot, { recursive: true });
rmSync(destDir, { recursive: true, force: true });
renameSync(staged, destDir); // atomic swap, same volume
log(`${name}: prebuilt binary installed`);
} catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`);
explainMissingPrebuild(name, version, cdnErr);
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name}`);
}
const after = probeRequire(name);
if (!after.ok) throw new Error(describeProbe(after));
backup.commit();
log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI})`);
return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
} catch (err) {
backup.restore();
log(`${name}: FAILED — ${err.message}`);
buildToolsHint();
return makeResult(name, "failed", err.message);
} finally {
if (staging) {
try {
rmSync(staging, { recursive: true, force: true });
} catch {
/* ignore */
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
// ---------------------------------------------------------------------------
// better-sqlite3 (REQUIRED) — the module the ABI bug actually bites
// ---------------------------------------------------------------------------
async function ensureBetterSqlite3() {
const name = "better-sqlite3";
const pkgDir = join(NODE_MODULES, name);
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
if (!existsSync(pkgDir)) {
log(`${name}: package not installed — run 'npm install' first`);
return makeResult(name, "missing", "package not installed");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
const before = probeRequire(name);
if (before.ok) {
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
}
// This is the case the old size check could not see: the file is there, it is
// ~1.9 MB, and it is completely useless because it targets another ABI.
log(`${name}: unusable — ${describeProbe(before)}`);
log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`);
const version = readInstalledVersion(name) || "12.11.1";
const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
const backup = stash(dest);
let staging = null;
try {
try {
log(`${name}: GET ${url}`);
const buf = await download(url);
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
await extractTarGz(buf, staging);
const staged = join(staging, "build", "Release", "better_sqlite3.node");
if (!existsSync(staged)) {
throw new Error("tarball did not contain build/Release/better_sqlite3.node");
}
mkdirSync(dirname(dest), { recursive: true });
rmSync(dest, { force: true });
renameSync(staged, dest); // atomic swap, same volume
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
} catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`);
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 */
}
}
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
// ---------------------------------------------------------------------------
// stamp
// ---------------------------------------------------------------------------
/** Record which ABI this install was built for. Lives inside node_modules so it
* dies together with the thing it describes. check-native.mjs reads it. */
function writeStamp(results) {
if (!existsSync(NODE_MODULES)) return;
const stamp = {
abi: NODE_ABI,
nodeVersion: process.version,
platform: PLATFORM,
arch: ARCH,
updatedAt: new Date().toISOString(),
modules: Object.fromEntries(results.map((r) => [r.name, r.status])),
};
try {
writeFileSync(STAMP_FILE, `${JSON.stringify(stamp, null, 2)}\n`);
log(`ABI stamp written: node_modules/.tsmusicbot-abi (Node ${process.version}, ABI ${NODE_ABI})`);
} catch (err) {
log(`WARN: could not write ABI stamp: ${err.message}`);
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
const STEPS = [
["ffmpeg-static", ensureFfmpeg],
["@discordjs/opus", ensureOpus],
["better-sqlite3", ensureBetterSqlite3],
];
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
log(`Node ${process.version} (ABI ${NODE_ABI}), ${PLATFORM}-${ARCH}, CDN ${CDN}`);
recoverOrphanedBackups();
// STRICTLY SEQUENTIAL, and it has to stay that way. The source-build fallback
// shells out through execSync, which parks the event loop for minutes; the
// 120s timeout that download() arms is a socket-INACTIVITY timer sitting on
// that same loop. Run these concurrently and the first module to fall back to
// a source build kills every download still in flight — the connection is
// healthy, the timer just never got a chance to be reset. That is not a rare
// race: @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) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
log(`ERROR: ${e.stack || e.message}`);
process.exitCode = 1;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Crash-proof line logging for the setup scripts.
*
* WHY THIS EXISTS (issue #152)
* ----------------------------
* setup.bat runs `chcp 65001` and shows progress on stderr. Some Windows
* consoles - Windows Server 2012 R2 above all - cannot render non-ASCII text in
* that code page and the OS fails the write with EIO. `process.stderr` is an
* ordinary stream, so that EIO arrives as an 'error' event, and a stream with
* no 'error' listener rethrows it as an uncaught exception:
*
* Error: write EIO
* at afterWriteDispatched (node:internal/stream_base_commons:159:15)
* ...
* at log (scripts/download-binaries.mjs:84:18)
* at ensureFfmpeg (scripts/download-binaries.mjs:451:5)
*
* That is setup killing itself inside its own progress logging, on the first
* line of the run that happened to contain Chinese - nothing was wrong with the
* download it was about to start.
*
* So: listen for the error and degrade instead of dying.
* full -> ascii : drop the CJK the console choked on, keep the English half
* ascii -> off : the stream is simply gone (closed pipe) - stay quiet
* Each stream degrades on its own, so a console that gives up does not cost
* setup.log its full bilingual transcript: that stdout is a redirected file.
*/
const HAS_NON_ASCII = /[^\x00-\x7F]/;
/** Placeholders for a removed run: one that separated words, one that did not. */
const SPACED = "\u0000";
const TIGHT = "\u0001";
/** Punctuation the bilingual strings use that has an obvious ASCII twin. */
const PUNCTUATION = new Map(
Object.entries({
"—": "-",
"–": "-",
"…": "...",
"“": '"',
"”": '"',
"‘": "'",
"’": "'",
",": ",",
"。": ".",
"、": ",",
":": ":",
";": ";",
"(": "(",
")": ")",
"!": "!",
"?": "?",
"←": "<-",
"→": "->",
"×": "x",
}),
);
/**
* Best-effort ASCII rendering of a log line, for a console that cannot print
* anything else. Returns null when nothing worth printing survives - every
* Chinese-only line in these scripts sits directly beside an English line
* saying the same thing, so dropping it loses no information.
*/
export function toAsciiFallback(text) {
if (!HAS_NON_ASCII.test(text)) return text;
let out = "";
for (const ch of text) out += PUNCTUATION.get(ch) ?? ch;
out = out
.replace(/[\u0000\u0001]/g, "")
// Mark each removed run rather than just deleting it, so the tidy-up below
// can tell "a separator that introduced text we dropped" from "a separator
// that belongs to the English half". SPACED was holding two ASCII words
// apart; TIGHT was hugging a bracket or a comma.
.replace(/[ \t]*[^\x00-\x7F]+[ \t]*/g, (run) =>
/^[ \t]/.test(run) && /[ \t]$/.test(run) ? SPACED : TIGHT,
)
// "(可能需要几分钟)" — the parentheses held nothing else.
.replace(/[ \t]*\([ \t]*(?:[\u0000\u0001][ \t]*)+\)/g, "")
// "FAILED — 编译失败", "(~80 MB, 请耐心等待)" — drop the trailing marks along
// with the separators that were only ever there to introduce them.
.replace(/[ \t]*[-,;:]*[ \t]*(?:[\u0000\u0001][ \t,;:-]*)+(?=[)\]]|$)/gm, "")
.replace(/\u0000/g, " ")
.replace(/\u0001/g, "")
.replace(/[ \t]+$/gm, "");
return /[A-Za-z0-9]/.test(out) ? out : null;
}
/** One degradation state per stream, shared by every writer built on it. */
const guards = new WeakMap();
function guardFor(stream) {
const existing = guards.get(stream);
if (existing) return existing;
const guard = { mode: "full" };
guards.set(stream, guard);
try {
// The whole point: without this listener the next EIO/EPIPE is fatal.
stream.on("error", () => degrade(guard));
} catch {
/* not an EventEmitter - the try/catch around write() still guards us */
}
return guard;
}
function degrade(guard) {
guard.mode = guard.mode === "full" ? "ascii" : "off";
}
/**
* Build a `writeLine(text)` that appends a newline, never throws, and never
* lets a failed console write take the process down with it.
* Returns true when the line reached the stream.
*/
export function createLineWriter(stream) {
const guard = guardFor(stream);
return function writeLine(text) {
if (guard.mode === "off") return false;
let line = text;
if (guard.mode === "ascii") {
line = toAsciiFallback(text);
if (line === null) return false;
}
try {
stream.write(`${line}\n`);
return true;
} catch {
// A synchronous throw (EBADF on a closed handle) never reaches the
// 'error' listener, so degrade here too.
degrade(guard);
return false;
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { createLineWriter, toAsciiFallback } from "./console-log.mjs";
/** Stand-in for process.stderr: an EventEmitter with a write() we can steer. */
function fakeStream() {
const stream = new EventEmitter();
stream.written = [];
stream.throwOnWrite = false;
stream.write = (chunk) => {
if (stream.throwOnWrite) throw new Error("EBADF");
stream.written.push(chunk);
return true;
};
return stream;
}
describe("toAsciiFallback", () => {
it("leaves ASCII lines exactly as they are", () => {
const line = " [binary] better-sqlite3: OK (loads under v22.23.2, ABI 127)";
expect(toAsciiFallback(line)).toBe(line);
expect(toAsciiFallback("")).toBe("");
});
it("keeps the English half of the line that crashed setup in #152", () => {
expect(
toAsciiFallback(
" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)",
),
).toBe(" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB)");
});
it("drops parentheses and separators left stranded by the removed text", () => {
expect(
toAsciiFallback(" [binary] better-sqlite3: falling back — 'npm rebuild' (可能需要几分钟)"),
).toBe(" [binary] better-sqlite3: falling back - 'npm rebuild'");
expect(
toAsciiFallback(" Windows: npm install --global windows-build-tools (或安装 VS Build Tools)"),
).toBe(" Windows: npm install --global windows-build-tools (VS Build Tools)");
});
it("drops a Chinese-only line, which always has an English twin beside it", () => {
expect(toAsciiFallback("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。")).toBeNull();
});
it("preserves the indentation the summary is aligned on, and drops the dangling dash", () => {
expect(toAsciiFallback(" - better-sqlite3 FAILED — 编译失败")).toBe(
" - better-sqlite3 FAILED",
);
});
it("keeps a separator that belongs to the English half", () => {
expect(toAsciiFallback("Summary — Node v22.0.0 / ABI 127 / win32-x64:")).toBe(
"Summary - Node v22.0.0 / ABI 127 / win32-x64:",
);
});
});
describe("createLineWriter", () => {
it("appends a newline and reports the write", () => {
const stream = fakeStream();
expect(createLineWriter(stream)("hello")).toBe(true);
expect(stream.written).toEqual(["hello\n"]);
});
it("survives the EIO that killed setup: an 'error' event must not throw", () => {
const stream = fakeStream();
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
expect(() => stream.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" }))).not.toThrow();
});
it("falls back to ASCII once the console has refused a line", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
write(" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)");
stream.emit("error", new Error("write EIO"));
write(" [binary] GET https://cdn/opus.tar.gz (~1 MB, 这一步比较慢,请耐心等待)");
expect(stream.written).toEqual([
" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)\n",
" [binary] GET https://cdn/opus.tar.gz (~1 MB)\n",
]);
});
it("goes quiet after a second failure rather than retrying a dead stream", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.emit("error", new Error("write EIO"));
stream.emit("error", new Error("write EPIPE"));
expect(write("anything at all")).toBe(false);
expect(stream.written).toEqual([]);
});
it("degrades on a synchronous throw too, which never reaches the listener", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.throwOnWrite = true;
expect(write(" [binary] 下载中 downloading")).toBe(false);
stream.throwOnWrite = false;
write(" [binary] 下载中 downloading");
expect(stream.written).toEqual([" [binary] downloading\n"]);
});
it("degrades each stream on its own, so setup.log keeps the full transcript", () => {
const console_ = fakeStream();
const logFile = fakeStream();
const writeConsole = createLineWriter(console_);
const writeLog = createLineWriter(logFile);
console_.emit("error", new Error("write EIO"));
const line = " [binary] ffmpeg-static: 下载完成 done";
writeConsole(line);
writeLog(line);
expect(console_.written).toEqual([" [binary] ffmpeg-static: done\n"]);
expect(logFile.written).toEqual([`${line}\n`]);
});
it("never installs a second listener for a stream that already has a writer", () => {
const stream = fakeStream();
createLineWriter(stream);
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
});
it("still guards a stream that is not an EventEmitter", () => {
const stream = { write: vi.fn(() => { throw new Error("EBADF"); }) };
const write = createLineWriter(stream);
expect(() => write("line")).not.toThrow();
expect(write("line")).toBe(false);
});
});
+65 -11
View File
@@ -10,8 +10,11 @@ title TSMusicBot Setup
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=22"
:: Newest Node major this project is regularly tested against. Anything above
:: still works, it just may have no prebuilt addons and fall back to a source build.
set "TESTED_NODE_MAJOR=22"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
@@ -64,13 +67,40 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
:: The supported floor is not just a major version, so let node decide.
:: Node 20 was dropped: better-sqlite3 ships no prebuilt binary for its ABI
:: (115) since 12.10.0, so every Node 20 install needed Python and a C++
:: toolchain just to get off the ground (issue #152). The odd majors (21 /
:: 23) are excluded by better-sqlite3 and vitest.
:: Keep this in sync with "engines" in package.json.
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
if errorlevel 1 (
call :error "Node.js %NODE_VER% is not supported. Use Node 22.12+ LTS or newer."
echo Download: https://nodejs.org/ or https://nodejs.cn/
pause
exit /b 1
)
:: Not fatal: setup now rebuilds the native modules for whatever ABI you run,
:: so newer Node majors work - they are just slower to install.
:: NOTE: keep every line inside these parenthesised blocks pure ASCII.
:: cmd.exe mis-tracks its file offset when a block contains multi-byte UTF-8
:: characters and starts eating the "echo " prefix of following lines.
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 22.
echo Newer Node majors may have no prebuilt opus / better-sqlite3,
echo so setup falls back to a source build - slower, needs C++ build tools.
echo Recommended: Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
echo This is only a warning; setup still builds the binaries for %NODE_VER%.
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
)
echo.
:: Native addons are tied to one Node ABI. If node_modules was built by a
:: different Node major, step 4b below detects it and repairs it.
call :log "Node ABI for this install: see node_modules\.tsmusicbot-abi after step 4b"
:: ============================================================
:: Step 2: Check npm
:: ============================================================
@@ -143,16 +173,40 @@ echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: Step 4b: Verify / download / repair native binaries (ABI aware)
:: ============================================================
call :step "4b/7" "Downloading native binaries"
call :step "4b/7" "Checking native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
echo Verifying native modules for %NODE_VER% and downloading whatever is missing.
echo Progress is shown below; the full transcript goes to the log file.
echo.
:: The .mjs writes progress to stderr and - with TSMB_BINARY_LOG_STDOUT=1 - the
:: same lines to stdout. Redirecting only stdout therefore keeps the log complete
:: while the user still sees live progress instead of a frozen window.
set "TSMB_BINARY_LOG_STDOUT=1"
node scripts\download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%"
set "BIN_RESULT=!errorlevel!"
set "TSMB_BINARY_LOG_STDOUT="
:: ASCII only inside these blocks - see the note near the Node version check.
if not "!BIN_RESULT!"=="0" (
set "FAILED=1"
call :error "A required native module is unusable - see the [binary] lines above."
echo Required: @discordjs/opus and better-sqlite3.
echo Full log: %LOG_FILE%
)
if "!FAILED!"=="1" (
echo.
echo Setup aborted. Fix the problem above and run this script again.
call :log "Setup aborted at step 4b"
pause
exit /b 1
)
echo [OK] Native binaries ready for %NODE_VER%.
echo ABI recorded in node_modules\.tsmusicbot-abi
echo.
:: ============================================================
+43 -6
View File
@@ -27,6 +27,28 @@ if ! command -v node &>/dev/null; then
fi
echo "[OK] Node.js $(node -v)"
# Newest Node major this project is regularly tested against. Anything above
# still works, it just may have no prebuilt addons and fall back to a source build.
TESTED_NODE_MAJOR=22
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
# The floor is not just a major version, so let node decide. Node 20 was dropped:
# better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
# Node 20 install needed Python and a C++ toolchain just to get off the ground
# (issue #152). The odd majors (21 / 23) are excluded by better-sqlite3 and vitest.
# Keep in sync with package.json "engines".
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
echo " https://nodejs.org/ | https://nodejs.cn/"
exit 1
fi
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 22)."
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
fi
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
@@ -73,15 +95,30 @@ npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_F
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
# ---- Step 2: Verify / download / repair native binaries (ABI aware) ----
echo "---- 2/5: Checking native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
# The old `if node ... | tee ...` only printed a [WARN] and carried on, so a
# broken native module still produced a "Setup Complete!" banner. It also read
# the *pipeline's* status: `set -o pipefail` above happens to surface node's
# failure, but a failing `tee` (unwritable log) was indistinguishable from a
# failing node. PIPESTATUS[0] is exactly node's own exit code, nothing else.
set +e
node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"
BIN_STATUS=${PIPESTATUS[0]}
set -e
if [ "$BIN_STATUS" -ne 0 ]; then
echo ""
echo "[ERROR] A required native module (@discordjs/opus / better-sqlite3) is unusable."
echo " 必需的原生模块不可用,安装中止。原因见上面的 [binary] 输出。"
echo " Log: $LOG_FILE"
exit 1
fi
# ffmpeg-static failures are only a WARN inside the script above (a system
# ffmpeg on PATH is a supported fallback), so reaching here means we are good.
echo "[OK] Native binaries ready for $(node -v)."
echo ""
# ---- Step 3: Install web panel dependencies ----
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Smoke test for issue #51 — run AFTER you start the bot from temp/preview-merge
# (or from main once both PRs are merged).
#
# Usage: ./scripts/smoke_issue51.sh [HOST]
# Default HOST is http://127.0.0.1:3000
set -e
HOST="${1:-http://127.0.0.1:3000}"
PASS=0
FAIL=0
note() { echo -e "\n=== $* ==="; }
ok() { echo " [PASS] $*"; PASS=$((PASS+1)); }
bad() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); }
# ---- Album search ----------------------------------------------------------
note "1. /api/music/search/all returns {songs,albums,playlists}"
RES=$(curl.exe -s "$HOST/api/music/search/all?q=%E5%91%A8%E6%9D%B0%E4%BC%A6") # 周杰伦
KEYS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(','.join(sorted(d.keys())))")
if [ "$KEYS" = "albums,playlists,songs" ]; then ok "keys = $KEYS"; else bad "keys = $KEYS (expected albums,playlists,songs)"; fi
NA=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('albums',[])))")
NS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))")
NP=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('playlists',[])))")
echo " songs=$NS, albums=$NA, playlists=$NP"
if [ "$NA" -gt 0 ]; then ok "albums populated"; else bad "albums empty (expected >0 for 周杰伦)"; fi
if [ "$NS" -gt 0 ]; then ok "songs populated"; fi
# ---- Album detail playback path -------------------------------------------
note "2. /api/music/album/:id returns songs"
if [ "$NA" -gt 0 ]; then
ALBUM_ID=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['id'])")
PLATFORM=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['platform'])")
echo " testing album id=$ALBUM_ID platform=$PLATFORM"
ASONGS=$(curl.exe -s "$HOST/api/music/album/$ALBUM_ID?platform=$PLATFORM" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))" 2>/dev/null || echo 0)
if [ "$ASONGS" -gt 0 ]; then ok "album returned $ASONGS songs"; else bad "album endpoint returned 0 songs"; fi
else
echo " (skipped — no albums to test)"
fi
# ---- Avatar API ------------------------------------------------------------
note "3. avatar GET 404 on bot with no avatar"
BOT_ID=$(curl.exe -s "$HOST/api/bot" | python3 -c "import json,sys;d=json.load(sys.stdin);bots=d.get('bots',[]);print(bots[0]['id'] if bots else '')")
if [ -z "$BOT_ID" ]; then bad "no bot found — create a bot first"; exit 1; fi
echo " using bot $BOT_ID"
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ] || [ "$CODE" = "200" ]; then ok "GET initial state = $CODE"; else bad "unexpected GET status $CODE"; fi
note "4. avatar PUT 200 + GET 200 round-trip"
# 1×1 transparent PNG (67 bytes)
TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
PUT_RES=$(curl.exe -s -X PUT "$HOST/api/bot/$BOT_ID/avatar" -H "Content-Type: application/json" \
-d "{\"dataUrl\":\"data:image/png;base64,$TINY_PNG_B64\"}")
echo " PUT response: $PUT_RES"
GOT_PATH=$(echo "$PUT_RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('path',''))" 2>/dev/null || echo "")
if [ "$GOT_PATH" = "$BOT_ID.png" ]; then ok "PUT returned path=$GOT_PATH"; else bad "PUT path = $GOT_PATH (expected $BOT_ID.png)"; fi
curl.exe -s -o /tmp/avatar_check.png -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
SIZE=$(wc -c < /tmp/avatar_check.png)
if [ "$CODE" = "200" ] && [ "$SIZE" -gt 60 ]; then ok "GET returned 200, $SIZE bytes"; else bad "GET status=$CODE size=$SIZE"; fi
note "5. avatar DELETE 204 + GET 404"
curl.exe -s -X DELETE "$HOST/api/bot/$BOT_ID/avatar" -o /dev/null -w "%{http_code}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "204" ]; then ok "DELETE returned 204"; else bad "DELETE status = $CODE"; fi
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ]; then ok "GET after DELETE returned 404"; else bad "GET after DELETE = $CODE"; fi
note "6. avatar PUT rejects oversize (>200KB)"
BIG_B64=$(node -e "console.log(Buffer.alloc(210*1024,7).toString('base64'))")
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/png;base64,$BIG_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "413" ]; then ok "oversize rejected with 413"; else bad "oversize status = $CODE (expected 413)"; fi
note "7. avatar PUT rejects bad MIME (image/gif)"
GIF_B64="R0lGODlhAQABAAAAACw=" # tiny gif
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/gif;base64,$GIF_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "400" ]; then ok "bad MIME rejected with 400"; else bad "bad MIME status = $CODE (expected 400)"; fi
# ---------------------------------------------------------------------------
echo ""
echo "============================================="
echo "SMOKE RESULT: $PASS passed, $FAIL failed"
echo "============================================="
[ "$FAIL" -eq 0 ]
+17 -1
View File
@@ -29,6 +29,19 @@ if not exist "dist" (
exit /b 1
)
:: Preflight: do the compiled native modules match THIS Node version?
:: Switching Node majors after setup leaves node_modules built for the old ABI;
:: without this check the bot dies mid-startup with a NODE_MODULE_VERSION stack.
:: check-native.mjs prints the bilingual explanation itself; keep the lines in
:: this block pure ASCII (cmd.exe garbles multi-byte text inside blocks).
node scripts\check-native.mjs
if errorlevel 1 (
echo.
echo Please run scripts\setup.bat to rebuild the native modules.
pause
exit /b 1
)
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
@@ -38,7 +51,10 @@ if errorlevel 1 (
)
:: Start the application
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist/index.js
pause
+462 -4
View File
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
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 { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
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");
@@ -53,13 +55,13 @@ describe("buildFfmpegArgs", () => {
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
it("inserts -ss after -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
const iIdx = args.indexOf("-i");
expect(ssIdx).toBeGreaterThan(-1);
expect(args[ssIdx + 1]).toBe("42");
expect(ssIdx).toBeLessThan(iIdx);
expect(ssIdx).toBeGreaterThan(iIdx);
});
it("does not insert -ss when seekSeconds is 0", () => {
@@ -200,3 +202,459 @@ describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
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();
}
});
});
+273 -32
View File
@@ -5,6 +5,7 @@ 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";
const require = createRequire(import.meta.url);
@@ -47,7 +48,7 @@ const resolvedFfmpeg: string = (() => {
return "ffmpeg";
})();
function getFfmpegCommand(): string {
export function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
@@ -102,8 +103,10 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
@@ -160,6 +163,15 @@ export class AudioPlayer extends EventEmitter {
private encoder: Encoder;
private state: PlayerState = "idle";
private volume = 75;
/**
* A transient gain envelope layered on top of the persisted user volume.
* Voice ducking drives this value; keeping it separate means a temporary
* attenuation can never leak into the saved volume setting.
*/
private duckingRampStartGain = 1;
private duckingTargetGain = 1;
private duckingRampStartedAt = 0;
private duckingRampDurationMs = 0;
private pcmBuffer: Buffer = Buffer.alloc(0);
private logger: Logger;
private frameLoopRunning = false;
@@ -187,6 +199,22 @@ export class AudioPlayer extends EventEmitter {
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();
this.encoder = createOpusEncoder();
@@ -390,6 +418,117 @@ export class AudioPlayer extends EventEmitter {
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++;
@@ -419,6 +558,11 @@ export class AudioPlayer extends EventEmitter {
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";
@@ -480,7 +624,17 @@ export class AudioPlayer extends EventEmitter {
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
// 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
@@ -505,20 +659,20 @@ export class AudioPlayer extends EventEmitter {
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
// 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");
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
return;
}
} else {
@@ -526,14 +680,15 @@ export class AudioPlayer extends EventEmitter {
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";
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
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;
}
@@ -542,13 +697,24 @@ export class AudioPlayer extends EventEmitter {
}
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);
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 {
@@ -566,20 +732,75 @@ export class AudioPlayer extends EventEmitter {
}
}
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 {
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const baseFactor = volumeToFactor(this.volume);
const now = performance.now();
const startDuckingGain = this.duckingGainAt(now);
const endDuckingGain = this.duckingGainAt(now + FRAME_DURATION_MS);
const startFactor = baseFactor * startDuckingGain;
const endFactor = baseFactor * endDuckingGain;
if (startFactor >= 1 && endFactor >= 1) {
return Buffer.from(pcm);
}
const out = Buffer.alloc(pcm.length);
// Most frames are outside the short attack/release windows. Preserve the
// old constant-factor hot path instead of doing interpolation per sample.
if (startFactor === endFactor) {
for (let i = 0; i < pcm.length; i += 2) {
const sample = Math.round(pcm.readInt16LE(i) * startFactor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
// PCM is fixed at stereo s16le. Use one gain for each L/R pair so a ramp
// never creates a tiny channel imbalance, and span the whole 20 ms frame.
const stereoFrames = Math.max(1, Math.ceil(pcm.length / 4));
for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
const frameIndex = Math.floor(i / 4);
const progress = stereoFrames === 1 ? 0 : frameIndex / (stereoFrames - 1);
const factor = startFactor + (endFactor - startFactor) * progress;
const sample = Math.round(pcm.readInt16LE(i) * factor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
private duckingGainAt(at: number): number {
if (this.duckingRampDurationMs <= 0) return this.duckingTargetGain;
const progress = Math.max(
0,
Math.min(1, (at - this.duckingRampStartedAt) / this.duckingRampDurationMs),
);
return (
this.duckingRampStartGain +
(this.duckingTargetGain - this.duckingRampStartGain) * progress
);
}
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
// (framesPlayed includes silence frames emitted on underrun) and therefore
// only APPROXIMATE — the authoritative position is the controller's live
// status.track.position. This approximation is acceptable for Spotify.
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void {
seek(seconds: number): void {
// 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);
}
@@ -589,5 +810,25 @@ export class AudioPlayer extends EventEmitter {
resetFailures(): void { this.consecutiveFailures = 0; }
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
getVolume(): number { return this.volume; }
/** Set the temporary voice-ducking gain (0=silent, 1=unchanged). */
setDuckingGain(gain: number, rampMs = 0): void {
if (!Number.isFinite(gain)) return;
const now = performance.now();
const currentGain = this.duckingGainAt(now);
const targetGain = Math.max(0, Math.min(1, gain));
const duration = Number.isFinite(rampMs) ? Math.max(0, rampMs) : 0;
this.duckingRampStartGain = currentGain;
this.duckingTargetGain = targetGain;
this.duckingRampStartedAt = now;
this.duckingRampDurationMs =
duration > 0 && currentGain !== targetGain ? duration : 0;
}
getDuckingGain(): number { return this.duckingGainAt(performance.now()); }
getState(): PlayerState { return this.state; }
}
// True only while attached to an external (Spotify sidecar) PCM stream. Used
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
// externalMode=false) so this is false after any player.stop().
isExternalActive(): boolean { return this.externalMode; }
}
+178
View File
@@ -564,4 +564,182 @@ describe("PlayQueue", () => {
}
});
});
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");
});
});
});
+87 -11
View File
@@ -10,10 +10,23 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
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 {
@@ -47,8 +60,12 @@ export class PlayQueue {
* or queue empty), so the existing "add → idle bot starts playing"
* flow continues to work.
*
* Shifts playedIndices and history entries > currentIndex by +1 so
* their references stay valid after the splice.
* Shifts playedIndices, history and forwardStack entries > currentIndex
* by +1 so their references stay valid after the splice.
*
* In the random modes the array position alone means nothing — next()
* picks from the shuffle bag — so the insert slot is also recorded on
* the forward stack, which next() consults first (issue #141).
*/
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
@@ -67,6 +84,23 @@ export class PlayQueue {
this.history = this.history.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
this.forwardStack = this.forwardStack.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
// Push AFTER the shift, or the slot we just claimed would be shifted
// too. Stacking makes repeated !pn play in the order the queue shows
// them (each insert lands in front of the previous one), matching what
// sequential mode does with the same array. Bounded like history: drop the
// OLDEST pending entry rather than refusing the newest, so the song the
// user just asked for is always the one that gets honoured.
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
this.forwardStack.push(insertAt);
if (this.forwardStack.length > PlayQueue.HISTORY_LIMIT) {
this.forwardStack.shift();
}
}
}
remove(index: number): QueuedSong | null {
@@ -93,6 +127,13 @@ export class PlayQueue {
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
// …and for the forward stack, which now also carries !pn insert slots
// (issue #141). Left unshifted, a removal elsewhere in the queue would
// silently repoint the entry at whatever song slid into that slot.
this.forwardStack = this.forwardStack
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
return removed;
}
@@ -145,15 +186,22 @@ export class PlayQueue {
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
// 优先回到前进栈记录的位置(prev 退回的歌,或 !pn 插入的歌)。
// Keep popping past entries that no longer point anywhere useful,
// the way prev() walks past stale history entries. Without the loop a
// prev() that pushed the current index would swallow the pending !pn
// entry behind it. The range check is belt-and-braces — addNext and
// remove keep the stack in sync — but an out-of-range index here would
// set currentIndex out of bounds and hand back `undefined`, which
// BotInstance.playNext reads as end-of-queue and stops playback.
while (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
if (target < 0 || target >= this.songs.length) continue;
if (target === this.currentIndex) continue;
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
// Shuffle bag: pick uniformly from the songs not yet played this
@@ -272,4 +320,32 @@ export class PlayQueue {
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 = [];
}
}
+1381 -1
View File
File diff suppressed because it is too large. Load diff
+913 -79
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;
}
}
+66
View File
@@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat
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 = {
@@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => {
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();
});
});
+38 -1
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import path from "node:path";
import {
BotInstance,
type BotInstanceOptions,
@@ -13,6 +14,8 @@ 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
@@ -70,11 +73,17 @@ export interface CreateBotParams {
export class BotManager extends EventEmitter {
private bots = new Map<string, BotInstance>();
private readonly managedVoiceClients = new ManagedVoiceClientRegistry();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
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;
@@ -92,7 +101,12 @@ export class BotManager extends EventEmitter {
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider
localProvider?: MusicProvider,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
spotifyDataDir?: string,
spotifyOAuth?: SpotifyOAuth,
jellyfinProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -100,6 +114,11 @@ export class BotManager extends EventEmitter {
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<{
@@ -137,10 +156,16 @@ export class BotManager extends EventEmitter {
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);
@@ -276,10 +301,16 @@ export class BotManager extends EventEmitter {
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);
@@ -329,10 +360,16 @@ export class BotManager extends EventEmitter {
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);
+119
View File
@@ -145,3 +145,122 @@ describe("BotProfileManager custom avatar precedence", () => {
expect(ts.clearCalls).toBe(0);
});
});
// #148: the persisted avatar is loaded in the BotInstance constructor, before
// tsClient.connect() has run. Loading it must not touch the wire at all.
describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("does not upload or clear anything when called before connect", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
expect(ts.fileTransferInitUpload).not.toHaveBeenCalled();
});
it("the loaded avatar is uploaded once onConnect fires", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([7, 7, 7]))).toBe(true);
});
it("survives a reconnect: onConnect re-applies the loaded avatar every time", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([8]));
pm.onConnect();
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(2);
});
it("loading null leaves the wire untouched and onConnect stays quiet", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(null);
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar still uploads immediately after connect (post-connect edit unchanged)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([1]));
pm.onConnect();
await flush();
ts.uploadCalls.length = 0;
pm.setCustomAvatar(Buffer.from([2, 2]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
describe("BotProfileManager channel description follows the bot (#159)", () => {
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
let channelEdits: () => string[];
beforeEach(() => {
ts = makeMockTs() as any;
ts.cid = 5n;
(ts as any).getChannelId = () => ts.cid;
channelEdits = () =>
(ts.sendCommandNoWait as any).mock.calls
.map((c: any[]) => c[0] as string)
.filter((cmd: string) => cmd.startsWith("channeledit"));
});
it("clears the old channel and fills the new one when moved while playing", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
expect(channelEdits()).toEqual([
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
]);
ts.cid = 9n;
await pm.onChannelMoved(9n);
const edits = channelEdits();
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
});
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
await pm.onSongChange(null);
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
});
it("a move while idle touches no channel description", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("a move is ignored when the channel description feature is off", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("an event for the channel the description is already in is a no-op", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
await pm.onChannelMoved(5n);
expect(channelEdits()).toHaveLength(1);
});
});
+61 -5
View File
@@ -32,6 +32,13 @@ export class BotProfileManager {
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/**
* Channel whose description currently holds our now-playing text, or null
* if we have not written one. Remembered so that when the bot is moved we
* can still clean up the channel it was taken out of (#159) — by then
* getChannelId() already reports the new channel.
*/
private channelDescCid: bigint | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
@@ -65,6 +72,20 @@ export class BotProfileManager {
// --- Public API ---
/**
* Store a persisted custom avatar WITHOUT touching TeamSpeak (#148).
*
* Used during BotInstance construction, when the TS connection does not
* exist yet: setCustomAvatar would immediately fire the three-step file
* transfer (fileTransferInitUpload → uploadFileData → clientupdate) against
* a client that has not connected, so the upload always failed and the
* saved avatar never appeared. onConnect() re-applies this.customAvatar
* once the handshake completes, so loading it silently here loses nothing.
*/
loadCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
/**
* Set/clear the persistent idle avatar. Pass null to remove.
*
@@ -120,6 +141,8 @@ export class BotProfileManager {
onConnect(): void {
this.generation++;
this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = {
avatar: false,
description: false,
@@ -136,6 +159,31 @@ export class BotProfileManager {
}
}
/**
* Called when the bot itself has been moved to another channel (#159).
* Clears the now-playing text from the channel it left and, if a song is
* playing, writes it to the channel it is in now.
*/
async onChannelMoved(newChannelId: bigint): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
const oldChannelId = this.channelDescCid;
if (oldChannelId === newChannelId) return;
try {
if (oldChannelId !== null) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${oldChannelId} channel_description=`,
);
this.channelDescCid = null;
}
} catch (err) {
this.handleFeatureError("channelDesc", err);
return;
}
if (this.currentSong) {
await this.updateChannelDescription(this.currentSong, newChannelId);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
@@ -386,18 +434,25 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
private async updateChannelDescription(
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
const channelId = targetChannelId ?? this.tsClient.getChannelId();
if (!song) {
// Prefer the channel we actually wrote to, in case a move event
// was missed and the bot is somewhere else now.
const target = this.channelDescCid ?? channelId;
if (target === 0n) return; // unknown channel
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
`channeledit cid=${target} channel_description=`,
);
this.channelDescCid = null;
return;
}
if (channelId === 0n) return; // unknown channel
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -408,6 +463,7 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
+53
View File
@@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => {
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
// Issue #139: `!play id <id>` matches the "<command> <subcommand> <arg>"
// shape of every other command. The colon form stays supported — users have
// it in their chat scrollback and in older docs.
it("parses the space-separated id form", () => {
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id 185868.")).toEqual({ id: "185868", platform: null });
});
it("does not mistake a word merely starting with 'id' for an id reference", () => {
expect(parseSongRef("idol")).toBeNull();
expect(parseSongRef("identity 185868")).toBeNull();
expect(parseSongRef("id")).toBeNull();
expect(parseSongRef("id:")).toBeNull();
// Two remaining tokens are a search phrase, not an id.
expect(parseSongRef("id die for you")).toBeNull();
});
// Without a colon, "id" is just a word — "ID 4" and "ID Bruno" are real track
// titles. The space form therefore only claims tokens that could actually be
// an id; everything else stays a search term.
it("only treats the space form as an id when the token looks like one", () => {
expect(parseSongRef("id Bruno")).toBeNull();
expect(parseSongRef("id Marshmello")).toBeNull();
expect(parseSongRef("id 4ever")).toBeNull();
// …while every real id shape is still accepted.
expect(parseSongRef("id 4")).toEqual({ id: "4", platform: null }); // numeric
expect(parseSongRef("id BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: null });
expect(parseSongRef("id 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); // QQ mid
expect(parseSongRef("id a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toEqual({
id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
platform: null,
}); // Jellyfin GUID / Kugou hash
});
it("keeps the colon form unrestricted, so a short or odd id still works", () => {
expect(parseSongRef("id:Bruno")).toEqual({ id: "Bruno", platform: null });
expect(parseSongRef("id: 4ever")).toEqual({ id: "4ever", platform: null });
});
it("does not let the space form swallow a pasted URL", () => {
// `id <url>` used to fall through to the URL branches; it still must.
expect(parseSongRef("id https://music.163.com/song?id=185868")).toEqual({
id: "185868",
platform: "netease",
});
expect(parseSongRef("id https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({
id: "004Z8Ihr0JIu5s",
platform: "qq",
});
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
+33 -6
View File
@@ -18,9 +18,22 @@ export interface SongRef {
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Could this token plausibly BE an id on a supported platform?
* - NetEase / Kugou numeric ids → all digits
* - BiliBili → BV + 8-12 alphanumerics
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
* → 11+ chars from the id alphabet
* Deliberately conservative: anything rejected here just stays an ordinary
* search term, which is what it almost certainly was.
*/
function looksLikeSongId(token: string): boolean {
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - `id <id>` / `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
@@ -30,11 +43,25 @@ export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// Explicit id — platform decided by the command's flags/default. The
// separator is a colon or plain whitespace, so `id <id>` matches the
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
// from a chat paste ("id:12345." / "id:12345)") — no supported id
// (numeric / BVID / mid) ends in those.
//
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
// has to keep resolving as a URL. So the space form only claims tokens that
// could actually be an id; anything else falls through to the URL branches
// below and ultimately to a plain search.
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
if (idPrefix) {
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
return { id, platform: null };
}
}
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
+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;
}
}
+499 -3
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, existsSync, readFileSync } from "node:fs";
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readFileSync,
readdirSync,
renameSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } 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");
@@ -205,3 +454,250 @@ describe("adminGroups normalization", () => {
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);
});
});
+362 -7
View File
@@ -1,4 +1,12 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } 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";
@@ -9,6 +17,96 @@ export interface GuestModeConfig {
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;
locale: "zh" | "en";
@@ -21,9 +119,24 @@ 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"
@@ -33,6 +146,26 @@ export interface BotConfig {
// 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 {
@@ -51,8 +184,14 @@ export function getDefaultConfig(): BotConfig {
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
voiceDucking: {
enabled: false,
volumePercent: 30,
},
idleTimeoutMinutes: 0,
localAudioEnabled: true,
savedQueuesEnabled: false,
playKeepsQueue: false,
publicUrl: "",
trustProxy: false,
guestMode: {
@@ -69,15 +208,99 @@ export function getDefaultConfig(): BotConfig {
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();
try {
const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>;
// 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 {
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.
@@ -118,20 +341,152 @@ export function loadConfig(path: string): BotConfig {
)
: 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,
};
} catch {
return defaults;
}
}
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;
}
}
/**
+169 -1
View File
@@ -2,7 +2,7 @@ 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", () => {
@@ -60,6 +60,7 @@ describe("database", () => {
album: "Test Album",
platform: "netease",
coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
});
botDb.addPlayHistory({
@@ -76,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", () => {
@@ -129,6 +131,92 @@ describe("database", () => {
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
const inst = {
id: "bot-ps",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
// Fresh row → in-memory defaults.
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
// Volume and play mode persist independently.
botDb.saveVolume("bot-ps", 42);
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
botDb.savePlayMode("bot-ps", "rloop");
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
botDb.saveBotInstance({ ...inst, autoStart: true });
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
});
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
// No row → defaults.
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
botDb.saveBotInstance({
id: "bot-v",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
botDb.saveVolume("bot-v", 250);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
botDb.saveVolume("bot-v", -10);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
botDb.savePlayMode("bot-v", "bogus");
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
});
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
const p = join(dir, "legacy.db");
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
const legacy = createDatabase(p);
legacy.db.exec("DROP TABLE bot_instances");
legacy.db.exec(`CREATE TABLE bot_instances (
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
)`);
legacy.db
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
.run("legacy-bot");
legacy.close();
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
const reopened = createDatabase(p);
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
expect(cols).toContain("volume");
expect(cols).toContain("play_mode");
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
reopened.close();
rmSync(dir, { recursive: true, force: true });
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
@@ -151,6 +239,86 @@ describe("database", () => {
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", () => {
+293 -4
View File
@@ -1,6 +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;
@@ -8,8 +48,9 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
coverUrl: string;
requestedBy?: string;
}
export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -54,6 +95,26 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true,
};
/**
* Per-bot player settings persisted across restarts (#125): the playback volume
* and play mode. These reset to defaults on process restart when kept only in
* memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row —
* exactly like the per-bot profile flags — and restored when the bot is (re)built.
*/
export interface PlayerSettings {
/** 0-100. */
volume: number;
/** PlayMode string: "seq" | "loop" | "random" | "rloop". */
playMode: string;
}
const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]);
export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = {
volume: 75,
playMode: "seq",
};
export interface FavoritePlaylist {
id: number;
userId: string;
@@ -74,12 +135,24 @@ export interface BotDatabase {
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getPlayerSettings(botId: string): PlayerSettings;
saveVolume(botId: string, volume: number): void;
savePlayMode(botId: string, playMode: string): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
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;
}
@@ -118,12 +191,27 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
// exactly as before until the user changes them.
if (!names.includes("volume")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
}
if (!names.includes("play_mode")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
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 {
@@ -137,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'))
);
@@ -153,6 +242,8 @@ function initTables(db: Database.Database): void {
serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
volume INTEGER NOT NULL DEFAULT 75,
play_mode TEXT NOT NULL DEFAULT 'seq',
identity TEXT
);
@@ -215,6 +306,28 @@ function initTables(db: Database.Database): void {
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'))
);
`);
}
@@ -265,8 +378,8 @@ export function createDatabase(dbPath: string): BotDatabase {
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(`
@@ -313,6 +426,12 @@ export function createDatabase(dbPath: string): BotDatabase {
WHERE id = @id
`);
const selectPlayerSettings = db.prepare(
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
);
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
@@ -334,11 +453,72 @@ export function createDatabase(dbPath: string): BotDatabase {
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) {
@@ -398,6 +578,36 @@ export function createDatabase(dbPath: string): BotDatabase {
});
},
getPlayerSettings(botId) {
const row = selectPlayerSettings.get(botId) as
| { volume: number | null; play_mode: string | null }
| undefined;
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
const volume = Number.isFinite(rawVol)
? Math.max(0, Math.min(100, Math.round(rawVol)))
: DEFAULT_PLAYER_SETTINGS.volume;
const playMode =
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
? row.play_mode
: DEFAULT_PLAYER_SETTINGS.playMode;
return { volume, playMode };
},
saveVolume(botId, volume) {
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
updateVolume.run(clamped, botId);
},
savePlayMode(botId, playMode) {
// Persist only recognized modes so a bad value can never poison the row.
if (!PLAY_MODES.has(playMode)) return;
updatePlayMode.run(playMode, botId);
},
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
@@ -424,6 +634,85 @@ export function createDatabase(dbPath: string): BotDatabase {
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();
},
+70 -3
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig, migrateLegacyConfig } 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";
@@ -8,6 +8,10 @@ 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";
@@ -27,6 +31,7 @@ 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() {
@@ -48,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();
@@ -57,6 +69,15 @@ async function main() {
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);
@@ -66,9 +87,46 @@ async function main() {
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,
qqProvider,
@@ -79,7 +137,12 @@ async function main() {
avatarStore,
permissions,
CONFIG_PATH,
localProvider
localProvider,
kugouProvider,
spotifyProvider,
SPOTIFY_DATA_DIR,
spotifyOAuth,
jellyfinProvider
);
await botManager.loadSavedBots();
@@ -90,6 +153,9 @@ async function main() {
qqProvider,
bilibiliProvider,
localProvider,
kugouProvider,
spotifyProvider,
jellyfinProvider,
database: db,
avatarStore,
config,
@@ -97,6 +163,7 @@ async function main() {
logger,
cookieStore,
staticDir: STATIC_DIR,
spotifyOAuth,
});
await webServer.start();
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
import type { Logger } from "../logger.js";
// Record every listen() the QQ sidecar makes so we can assert it is always
// pinned to the configured port (regression coverage for issue #122).
const mockState = vi.hoisted(() => ({
listenCalls: [] as Array<{ port: number; host: string }>,
}));
vi.mock("@sansenjian/qq-music-api", () => {
const app = {
listen(port: number, host: string, cb?: () => void) {
mockState.listenCalls.push({ port, host });
const server = {
address: () => ({ port, address: host, family: "IPv4" as const }),
on() {
return server;
},
close(done?: () => void) {
done?.();
},
};
// Real net/Koa fire the listening callback on a later tick, after the
// caller has captured the returned server handle.
if (cb) setImmediate(cb);
return server;
},
};
return { default: app };
});
describe("describeQqApiStartupError", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
expect(hint).toMatch(/~2\.4\.0/);
expect(hint).toMatch(/~2\.2\.10/);
});
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
const hint = describeQqApiStartupError(
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
);
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
});
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
expect(hint).toMatch(/Node >=20\.17/);
expect(hint).toMatch(/~2\.2\.10/);
});
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
expect(describeQqApiStartupError(undefined)).toBeNull();
expect(describeQqApiStartupError(null)).toBeNull();
});
});
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
// the same port the client base URL targets (config.qqMusicApiPort). A stale
// build once bound 3300 while the client requested 3200, silently breaking the
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
describe("createApiServerManager — QQ sidecar port binding", () => {
const noopLogger = {
info() {},
warn() {},
error() {},
debug() {},
trace() {},
fatal() {},
} as unknown as Logger;
beforeEach(() => {
mockState.listenCalls = [];
});
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
const port = 39217; // uncommon port to avoid clashing with a real instance
const manager = createApiServerManager(
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
await manager.start();
manager.stop();
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
});
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
const port = 39219;
const previous = process.env.PORT;
// Simulate a hosting platform / compose file injecting a stray PORT that
// must NOT leak into the QQ sidecar's chosen port.
process.env.PORT = "39999";
const manager = createApiServerManager(
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// The sidecar follows qqMusicPort, never the injected PORT.
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
// The injected PORT is restored so nothing else in the process is affected.
expect(process.env.PORT).toBe("39999");
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
it("leaves an absent PORT env unset after importing the sidecar", async () => {
const port = 39221;
const previous = process.env.PORT;
delete process.env.PORT;
const manager = createApiServerManager(
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// Was unset before importing — must be unset again, no leaked override.
expect(process.env.PORT).toBeUndefined();
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
});
+115 -32
View File
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
export interface ApiServerOptions {
neteasePort: number;
qqMusicPort: number;
/** Provider gating (#enabledProviders): when false, the corresponding
* embedded sidecar API server is never started and its port never bound. */
neteaseEnabled?: boolean;
qqEnabled?: boolean;
}
export interface ApiServerManager {
@@ -14,6 +18,32 @@ export interface ApiServerManager {
getQQMusicBaseUrl(): string;
}
/**
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
* actionable operator guidance, or null when it isn't a recognised
* dependency/runtime mismatch. Exported for testing.
*
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
* that needs Node >=20.17 — either way the embedded server never binds, so
* every QQ request fails downstream with ECONNREFUSED on the API port.
*/
export function describeQqApiStartupError(err: unknown): string | null {
const e = (err ?? {}) as { code?: string; message?: string };
const code = String(e.code ?? "");
const msg = String(e.message ?? "");
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
return (
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
);
}
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
}
return null;
}
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
@@ -39,34 +69,43 @@ export function createApiServerManager(
return {
async start(): Promise<void> {
// Provider gating: with the jellyfin-only default config neither legacy
// sidecar starts, so ports 3001/3200 are never opened.
if (options.neteaseEnabled === false && options.qqEnabled === false) {
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
return;
}
logger.info("Starting embedded music API servers...");
// Start NetEase Cloud Music API
try {
const portFree = await isPortFree(options.neteasePort);
if (!portFree) {
logger.info(
{ port: options.neteasePort },
"NetEase API port already in use — reusing existing instance"
);
} else {
const ncmModule = await import("NeteaseCloudMusicApi") as any;
const serverObj = ncmModule.server ?? ncmModule.default?.server;
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
neteaseServer = app;
logger.info(
{ port: options.neteasePort },
"NetEase Cloud Music API started"
);
if (options.neteaseEnabled !== false) {
try {
const portFree = await isPortFree(options.neteasePort);
if (!portFree) {
logger.info(
{ port: options.neteasePort },
"NetEase API port already in use — reusing existing instance"
);
} else {
const ncmModule = await import("NeteaseCloudMusicApi") as any;
const serverObj = ncmModule.server ?? ncmModule.default?.server;
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
neteaseServer = app;
logger.info(
{ port: options.neteasePort },
"NetEase Cloud Music API started"
);
}
} catch (err) {
logger.error({ err }, "Failed to start NetEase Cloud Music API");
}
} catch (err) {
logger.error({ err }, "Failed to start NetEase Cloud Music API");
}
// Start QQ Music API. Older versions auto-started on import; the
// current fork (2.2.11+) only listens when run as `require.main`,
// so we explicitly call .listen() on the imported Koa app and keep
// the server handle for clean shutdown.
if (options.qqEnabled === false) return;
try {
const portFree = await isPortFree(options.qqMusicPort);
if (!portFree) {
@@ -75,7 +114,25 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance"
);
} else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
// Pin the upstream server to the configured port before importing.
// The package derives its default port from process.env.PORT (falling
// back to 3200) and, in some historical versions, auto-started that
// server as an import side effect. Aligning PORT with qqMusicApiPort
// guarantees the sidecar can never bind a different port than the one
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
// issue #122, where an old build listened on 3300 while the client
// requested 3200. Restore the previous value right after import so we
// never leak the override into the rest of the process (e.g. the web
// server or the NetEase sidecar, which also read PORT as a fallback).
const prevPortEnv = process.env.PORT;
process.env.PORT = String(options.qqMusicPort);
let qqModule: any;
try {
qqModule = (await import("@sansenjian/qq-music-api")) as any;
} finally {
if (prevPortEnv === undefined) delete process.env.PORT;
else process.env.PORT = prevPortEnv;
}
// The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app
@@ -85,25 +142,51 @@ export function createApiServerManager(
? candidate
: candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv)
// A version that auto-started on import has already bound the
// configured port (thanks to the PORT alignment above); reuse it
// rather than racing a second listen that would fail EADDRINUSE.
const stillFree = await isPortFree(options.qqMusicPort);
if (!stillFree) {
logger.info(
{ port: options.qqMusicPort },
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
);
srv.on("error", reject);
});
logger.info(
{ port: options.qqMusicPort },
"QQ Music API started"
);
} else {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv)
);
srv.on("error", reject);
});
// Log the port actually bound (read from the socket) rather than
// the requested one, so operators can spot a mismatch in the logs.
const addr = qqMusicServer.address();
const boundPort =
addr && typeof addr === "object" && addr !== null
? addr.port
: options.qqMusicPort;
logger.info(
{ port: boundPort },
"QQ Music API started"
);
}
} else {
logger.warn("QQ Music API module does not expose a Koa app");
}
}
} catch (err) {
logger.warn(
{ err },
"QQ Music API not available — QQ Music features may be limited"
);
const hint = describeQqApiStartupError(err);
if (hint) {
logger.error(
{ err },
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
);
} else {
logger.warn(
{ err },
"QQ Music API not available — QQ Music features may be limited"
);
}
}
},
+8 -4
View File
@@ -1,9 +1,13 @@
import fs from "node:fs";
import path from "node:path";
/** Platforms with a persisted credential blob. For jellyfin the "cookie" is a
* JSON string carrying the access token / userId / deviceId (see jellyfin.ts). */
type CookiePlatform = "netease" | "qq" | "bilibili" | "kugou" | "jellyfin";
export interface CookieStore {
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
load(platform: "netease" | "qq" | "bilibili"): string;
save(platform: CookiePlatform, cookie: string): void;
load(platform: CookiePlatform): string;
}
export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
}
return {
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
save(platform: CookiePlatform, cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync(
filePath,
@@ -21,7 +25,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
);
},
load(platform: "netease" | "qq" | "bilibili"): string {
load(platform: CookiePlatform): string {
const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return "";
try {
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
const p = new BiliBiliProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
// Short-circuit the buvid + wbi bootstrap so search only issues the
// /search/type request we want to inspect.
(p as any).buvidInitialized = true;
(p as any).wbiMixinKey = "0".repeat(32);
(p as any).wbiKeyFetchedAt = Date.now();
(p as any).api = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls.find(
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
);
expect(call, "expected a /search/type call").toBeTruthy();
// signWbi stringifies every value.
return call![1].params as Record<string, string>;
}
it("adds page (offset/limit+1) alongside page_size", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe("2");
expect(params.page_size).toBe("20");
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe("1");
});
});
describe("BiliBiliProvider multi-P support", () => {
it("parseBilibiliId extracts bvid and page correctly", async () => {
const { parseBilibiliId } = await import("./bilibili.js");
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
bvid: "BV1yxHQeYEuE",
page: 5,
});
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
});
function mockViewProvider(viewData: any, playUrlData?: any) {
const p = new BiliBiliProvider();
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
if (url === "/x/web-interface/view") {
return Promise.resolve({ data: { data: viewData } });
}
if (url === "/x/player/playurl") {
return Promise.resolve({ data: { data: playUrlData ?? {} } });
}
return Promise.resolve({ data: {} });
});
(p as any).buvidInitialized = true;
(p as any).api = { get };
return { p, get };
}
const multiPViewData = {
bvid: "BV1multiP",
title: "测试多P教程",
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
duration: 300, // 总时长 300 秒 (120 + 180)
owner: { name: "UP主测试" },
pages: [
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
],
};
const singlePViewData = {
bvid: "BV1singleP",
title: "测试单P视频",
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
duration: 200,
owner: { name: "UP主测试" },
pages: [
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
],
};
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
const { p } = mockViewProvider(singlePViewData);
const song = await p.getSongDetail("BV1singleP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1singleP");
expect(song!.name).toBe("测试单P视频");
expect(song!.duration).toBe(200);
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=1");
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP?p=2");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=2");
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
expect(song!.duration).toBe(180); // P2 独立时长
expect(song!.platform).toBe("bilibili");
});
it("getVideoParts returns all parts with duration and cid", async () => {
const { p } = mockViewProvider(multiPViewData);
const partsResult = await p.getVideoParts("BV1multiP");
expect(partsResult).not.toBeNull();
expect(partsResult!.bvid).toBe("BV1multiP");
expect(partsResult!.title).toBe("测试多P教程");
expect(partsResult!.parts).toHaveLength(2);
expect(partsResult!.parts[0]).toEqual({
part: 1,
cid: 10001,
title: "第一讲 入门",
duration: 120,
});
expect(partsResult!.parts[1]).toEqual({
part: 2,
cid: 10002,
title: "第二讲 进阶",
duration: 180,
});
});
it("getSongUrl requests playurl with correct cid for specific part", async () => {
const playUrlResponse = {
dash: {
audio: [
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
],
},
};
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
const result = await p.getSongUrl("BV1multiP?p=2");
expect(result).not.toBeNull();
expect(result!.url).toBe("http://audio.320k.test");
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
expect(playurlCall).toBeTruthy();
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
+111 -12
View File
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export interface BiliVideoPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliVideoPartsResult {
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliVideoPart[];
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
const str = (songId ?? "").trim();
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
if (!bvMatch) {
return { bvid: str, page: 1 };
}
const bvid = bvMatch[0];
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
const page = pageStr ? parseInt(pageStr, 10) : 1;
return { bvid, page: Math.max(1, page) };
}
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -147,12 +179,16 @@ export class BiliBiliProvider implements MusicProvider {
return fixed;
}
async search(query: string, limit = 20): Promise<SearchResult> {
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie();
await this.ensureWbiKeys();
// /search/type is page-based; the web pages in limit-aligned steps so
// offset is a multiple of page_size.
const page = Math.floor(offset / limit) + 1;
const signed = this.signWbi({
search_type: "video",
keyword: query,
page,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
@@ -188,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
const { bvid, page } = parseBilibiliId(songId);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
params: { bvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
// Cache cid for later audio URL fetching
if (data.pages?.[0]?.cid) {
this.cidCache.set(songId, data.pages[0].cid);
const pages = data.pages ?? [];
// 缓存所有分P的 cid 映射
for (const p of pages) {
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(bvid, pages[0].cid);
}
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
// 若为多P视频,返回对应分P的名称与独立时长
if (pages.length > 1 && targetPage) {
const partTitle = targetPage.part && targetPage.part !== data.title
? `${data.title} - P${targetPage.page} ${targetPage.part}`
: `${data.title} (P${targetPage.page})`;
return {
id: `${bvid}?p=${targetPage.page}`,
name: partTitle,
artist: data.owner?.name ?? "",
album: "",
duration: targetPage.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
}
return {
@@ -207,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
duration: targetPage?.duration ?? data.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
@@ -216,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
}
}
/** 获取视频所有分P列表 */
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
const { bvid: cleanBvid } = parseBilibiliId(bvid);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: cleanBvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
const pages = data.pages ?? [];
for (const p of pages) {
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(cleanBvid, pages[0].cid);
}
return {
bvid: cleanBvid,
title: data.title ?? "",
coverUrl: this.normalizeCover(data.pic ?? ""),
artist: data.owner?.name ?? "",
parts: pages.map((p: any) => ({
part: p.page,
cid: p.cid,
title: p.part ?? `P${p.page}`,
duration: p.duration ?? 0,
})),
};
} catch {
return null;
}
}
/** Get CID for a bvid, using cache when available */
private async getCid(bvid: string): Promise<number | null> {
const cached = this.cidCache.get(bvid);
private async getCid(bvid: string, page = 1): Promise<number | null> {
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
if (cached) return cached;
// Limit cache size to prevent unbounded growth
@@ -227,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
if (firstKey) this.cidCache.delete(firstKey);
}
const detail = await this.getSongDetail(bvid);
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
const detail = await this.getSongDetail(songId);
if (!detail) return null;
return this.cidCache.get(bvid) ?? null;
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
const { bvid, page } = parseBilibiliId(songId);
const cid = await this.getCid(bvid, page);
if (!cid) return null;
try {
const res = await this.api.get("/x/player/playurl", {
params: {
cid,
bvid: songId,
bvid,
fnval: 16, // DASH format
},
headers: this.cookieHeaders,
+386
View File
@@ -0,0 +1,386 @@
import { describe, it, expect, vi } from "vitest";
import {
ticksToSeconds,
TICKS_PER_SECOND,
buildCoverUrl,
buildStreamUrl,
buildUniversalUrl,
mapJellyfinSong,
mapJellyfinAlbum,
mapJellyfinPlaylist,
mapJellyfinLyrics,
describeJellyfinError,
JELLYFIN_QUALITY_LEVELS,
JellyfinProvider,
} from "./jellyfin.js";
import type { JellyfinConfig } from "../data/config.js";
function cfg(partial: Partial<JellyfinConfig> = {}): JellyfinConfig {
return {
serverUrl: "https://jf.example.com",
authMode: "apikey",
username: "",
password: "",
apiKey: "KEY",
userId: "user-1",
...partial,
};
}
/** Provider with a mocked axios instance; returns the request spy. */
function mockProvider(config = cfg(), responder?: (req: any) => any) {
const p = new JellyfinProvider();
p.configure(config);
const request = vi.fn(async (req: any) => ({
data: responder ? responder(req) : { Items: [] },
}));
(p as any).api = { request, post: vi.fn() };
return { p, request };
}
describe("Jellyfin mapping helpers", () => {
it("converts ticks to seconds (1 tick = 100ns)", () => {
expect(ticksToSeconds(TICKS_PER_SECOND)).toBe(1);
expect(ticksToSeconds(2_275_000_000)).toBeCloseTo(227.5, 3);
expect(ticksToSeconds(undefined)).toBe(0);
expect(ticksToSeconds(NaN)).toBe(0);
});
it("maps a Jellyfin audio item to Song (GUID id stays a string)", () => {
const song = mapJellyfinSong(
{
Id: "3fa85f6457174562b3fc2c963f66afa6",
Name: "Track",
Artists: ["A", "B"],
AlbumArtist: "A",
Album: "The Album",
RunTimeTicks: 1_855_000_000,
},
"https://jf/x.jpg",
);
expect(song).toEqual({
id: "3fa85f6457174562b3fc2c963f66afa6",
name: "Track",
artist: "A / B",
album: "The Album",
duration: 186, // 185.5s rounded
coverUrl: "https://jf/x.jpg",
platform: "jellyfin",
});
});
it("falls back to AlbumArtist when Artists is empty", () => {
const song = mapJellyfinSong({ Id: "1", Artists: [], AlbumArtist: "Solo" }, "");
expect(song.artist).toBe("Solo");
});
it("maps albums and playlists with ChildCount", () => {
const album = mapJellyfinAlbum(
{ Id: "a1", Name: "LP", AlbumArtist: "X", ChildCount: 10 },
"c",
);
expect(album).toMatchObject({ id: "a1", artist: "X", songCount: 10, platform: "jellyfin" });
const pl = mapJellyfinPlaylist({ Id: "p1", Name: "Mix", ChildCount: 7 }, "c");
expect(pl).toMatchObject({ id: "p1", songCount: 7, platform: "jellyfin" });
});
it("cover URL prefers the item's Primary image, falls back to the album's", () => {
const own = buildCoverUrl("https://jf", "K", {
Id: "i1",
ImageTags: { Primary: "tag1" },
AlbumId: "a1",
AlbumPrimaryImageTag: "tag2",
});
expect(own).toBe("https://jf/Items/i1/Images/Primary?maxWidth=512&tag=tag1&api_key=K");
const album = buildCoverUrl("https://jf", "K", {
Id: "i1",
AlbumId: "a1",
AlbumPrimaryImageTag: "tag2",
});
expect(album).toBe("https://jf/Items/a1/Images/Primary?maxWidth=512&tag=tag2&api_key=K");
expect(buildCoverUrl("https://jf", "K", { Id: "i1" })).toBe("");
});
it("builds direct and transcoded stream URLs", () => {
expect(buildStreamUrl("https://jf", "K", "i1")).toBe(
"https://jf/Audio/i1/stream?static=true&api_key=K",
);
const url = buildUniversalUrl("https://jf", {
apiKey: "K",
userId: "u",
deviceId: "d",
itemId: "i1",
kbps: 320,
});
expect(url).toContain("https://jf/Audio/i1/universal?");
expect(url).toContain("maxStreamingBitrate=320000");
expect(url).toContain("transcodingContainer=mp3");
expect(url).toContain("transcodingProtocol=http");
// The container list must be URL-encoded ("|" → %7C)
expect(url).toContain("container=mp3%2Caac%2Cm4a%7Caac%2Cflac%2Cwebma%2Cwebm%2Cwav%2Cogg");
});
it("maps lyrics with tick offsets; entries without Start collapse to 0; empty text dropped", () => {
const lines = mapJellyfinLyrics({
Lyrics: [
{ Text: "line two", Start: 125_000_000 },
{ Text: "line one", Start: 5_000_000 },
{ Text: " ", Start: 1 },
{ Text: "untimed" },
],
});
expect(lines).toEqual([
{ time: 0, text: "untimed" },
{ time: 0.5, text: "line one" },
{ time: 12.5, text: "line two" },
]);
});
it("mapJellyfinLyrics tolerates junk payloads", () => {
expect(mapJellyfinLyrics(null)).toEqual([]);
expect(mapJellyfinLyrics({})).toEqual([]);
expect(mapJellyfinLyrics({ Lyrics: "nope" })).toEqual([]);
});
it("describes common connection errors in a friendly way", () => {
expect(describeJellyfinError({ response: { status: 401 } })).toContain("401");
expect(describeJellyfinError({ code: "ECONNREFUSED" })).toContain("无法连接");
expect(describeJellyfinError(new Error("boom"))).toBe("boom");
});
});
describe("JellyfinProvider", () => {
it("quality: defaults to direct, ignores foreign (NetEase) values", () => {
const p = new JellyfinProvider();
expect(p.getQuality()).toBe("direct");
p.setQuality("exhigh"); // NetEase value from the legacy platform-less broadcast
expect(p.getQuality()).toBe("direct");
p.setQuality("320");
expect(p.getQuality()).toBe("320");
expect(JELLYFIN_QUALITY_LEVELS[0].value).toBe("direct");
});
it("getSongUrl: direct tier → static stream; 320 tier → universal transcode", async () => {
const { p } = mockProvider();
p.setCookie(JSON.stringify({ deviceId: "dev-1" }));
const direct = await p.getSongUrl("item1");
expect(direct?.url).toBe("https://jf.example.com/Audio/item1/stream?static=true&api_key=KEY");
p.setQuality("320");
const transcoded = await p.getSongUrl("item1");
expect(transcoded?.url).toContain("/Audio/item1/universal?");
expect(transcoded?.url).toContain("maxStreamingBitrate=320000");
expect(transcoded?.url).toContain("api_key=KEY");
});
it("search hits /Items for Audio, MusicAlbum and Playlist with paging", async () => {
const { p, request } = mockProvider();
await p.search("mozart", 30, 60);
const types = request.mock.calls.map((c) => c[0].params?.IncludeItemTypes);
expect(types).toContain("Audio");
expect(types).toContain("MusicAlbum");
expect(types).toContain("Playlist");
for (const call of request.mock.calls) {
expect(call[0].params.Limit).toBe(30);
expect(call[0].params.StartIndex).toBe(60);
expect(call[0].params.userId).toBe("user-1");
expect(call[0].headers["X-Emby-Token"]).toBe("KEY");
}
});
it("userpass: authenticates once via AuthenticateByName, persists token, retries once on 401", async () => {
const p = new JellyfinProvider();
p.configure(cfg({ authMode: "userpass", username: "eric", password: "pw", apiKey: "", userId: "" }));
const persisted: string[] = [];
p.setPersist((s) => persisted.push(s));
let tokenCounter = 0;
const post = vi.fn(async (..._args: any[]) => ({
data: { AccessToken: `tok-${++tokenCounter}`, User: { Id: "u9" } },
}));
// First data request 401s (expired token), the retry succeeds.
let dataCalls = 0;
const request = vi.fn(async (req: any) => {
dataCalls++;
if (dataCalls === 1) {
const err: any = new Error("Unauthorized");
err.response = { status: 401 };
throw err;
}
return { data: { Items: [{ Id: "s1", Name: "N" }] }, config: req };
});
(p as any).api = { request, post };
const songs = await p.getPlaylistSongs("pl1");
expect(songs).toHaveLength(1);
// login ran twice: initial auth + re-auth after the 401
expect(post).toHaveBeenCalledTimes(2);
const authHeader = post.mock.calls[0][2].headers.Authorization as string;
expect(authHeader).toContain('MediaBrowser Client="TSMusicBot"');
expect(authHeader).toContain("DeviceId=");
expect(post.mock.calls[0][1]).toEqual({ Username: "eric", Pw: "pw" });
// token + userId + deviceId persisted like cookies
const last = JSON.parse(persisted[persisted.length - 1]);
expect(last.accessToken).toBe("tok-2");
expect(last.userId).toBe("u9");
expect(last.deviceId).toBeTruthy();
});
it("userpass: surfaces the error when the retry also 401s (single retry only)", async () => {
const p = new JellyfinProvider();
p.configure(cfg({ authMode: "userpass", username: "eric", password: "bad", apiKey: "" }));
const post = vi.fn(async () => ({ data: { AccessToken: "t", User: { Id: "u" } } }));
const err: any = new Error("Unauthorized");
err.response = { status: 401 };
const request = vi.fn(async () => {
throw err;
});
(p as any).api = { request, post };
await expect(p.getPlaylistSongs("pl1")).rejects.toThrow("Unauthorized");
expect(request).toHaveBeenCalledTimes(2); // original + exactly one retry
});
it("setCookie/getCookie round-trip restores token, userId and deviceId", () => {
const p = new JellyfinProvider();
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
p.setCookie("not json"); // junk must not corrupt state
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
});
it("getLyrics: 404 means no lyrics (empty), other errors surface", async () => {
const notFound: any = new Error("nf");
notFound.response = { status: 404 };
const { p } = mockProvider(cfg(), () => {
throw notFound;
});
expect(await p.getLyrics("i1")).toEqual([]);
const boom: any = new Error("server down");
boom.response = { status: 500 };
const { p: p2 } = mockProvider(cfg(), () => {
throw boom;
});
await expect(p2.getLyrics("i1")).rejects.toThrow("server down");
});
it("getAuthStatus reflects /System/Info reachability; unconfigured = logged out", async () => {
const blank = new JellyfinProvider();
expect(await blank.getAuthStatus()).toEqual({ loggedIn: false });
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS" }));
expect(await p.getAuthStatus()).toEqual({ loggedIn: true, nickname: "API Key@NAS" });
const { p: down } = mockProvider(cfg(), () => {
throw new Error("ECONNREFUSED");
});
expect(await down.getAuthStatus()).toEqual({ loggedIn: false });
});
it("getPersonalFm seeds InstantMix from a random favorite", async () => {
const { p, request } = mockProvider(cfg(), (req: any) => {
if (req.url.includes("/InstantMix")) {
return { Items: [{ Id: "m1", Name: "Mixed" }] };
}
if (req.params?.Filters === "IsFavorite") {
return { Items: [{ Id: "fav1", Name: "Fav" }] };
}
return { Items: [] };
});
const songs = await p.getPersonalFm();
expect(songs.map((s) => s.id)).toEqual(["m1"]);
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
expect(mixCall![0].url).toContain("/Items/fav1/InstantMix");
});
it("getPersonalFm falls back: favorites → recently played → random", async () => {
const { p, request } = mockProvider(cfg(), (req: any) => {
if (req.url.includes("/InstantMix")) return { Items: [{ Id: "m2" }] };
if (req.params?.Filters === "IsFavorite") return { Items: [] };
if (req.params?.Filters === "IsPlayed") return { Items: [{ Id: "recent1" }] };
return { Items: [] };
});
const songs = await p.getPersonalFm();
expect(songs.map((s) => s.id)).toEqual(["m2"]);
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
expect(mixCall![0].url).toContain("/Items/recent1/InstantMix");
});
it("playback reporter posts start/progress/stopped with PositionTicks", async () => {
const bodies: { url: string; data: any }[] = [];
const { p } = mockProvider(cfg(), () => ({}));
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
async (req: any) => {
bodies.push({ url: req.url, data: req.data });
return { data: {} };
},
);
const reporter = p.createPlaybackReporter();
reporter.onTrackStart("song1");
reporter.onTick("song1", 90.5, false);
reporter.onStop();
await vi.waitFor(() => expect(bodies).toHaveLength(3));
expect(bodies[0].url).toContain("/Sessions/Playing");
expect(bodies[0].data.ItemId).toBe("song1");
const sessionId = bodies[0].data.PlaySessionId;
expect(sessionId).toBeTruthy();
expect(bodies[1].url).toContain("/Sessions/Playing/Progress");
expect(bodies[1].data.PositionTicks).toBe(905_000_000);
expect(bodies[1].data.PlaySessionId).toBe(sessionId);
// Stop carries the LAST reported position so a natural track end counts as played.
expect(bodies[2].url).toContain("/Sessions/Playing/Stopped");
expect(bodies[2].data.PositionTicks).toBe(905_000_000);
});
it("reporter: a new track start closes the previous session; failures are swallowed", async () => {
const bodies: { url: string; data: any }[] = [];
const { p } = mockProvider(cfg(), () => ({}));
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
async (req: any) => {
bodies.push({ url: req.url, data: req.data });
if (req.url.includes("/Sessions")) throw new Error("reporting endpoint down");
return { data: {} };
},
);
const reporter = p.createPlaybackReporter();
reporter.onTrackStart("a");
reporter.onTick("a", 200, false);
reporter.onTrackStart("b"); // must post Stopped for "a" at pos 200, then Playing for "b"
reporter.onStop();
await vi.waitFor(() => expect(bodies.length).toBeGreaterThanOrEqual(5));
const stopped = bodies.filter((b) => b.url.includes("/Stopped"));
expect(stopped[0].data.ItemId).toBe("a");
expect(stopped[0].data.PositionTicks).toBe(2_000_000_000);
expect(stopped[1].data.ItemId).toBe("b");
// ticks for stale items are ignored
reporter.onTick("a", 999, false);
expect(bodies.filter((b) => b.url.includes("/Progress"))).toHaveLength(1);
});
it("testConnection reports server info on success and friendly errors on failure", async () => {
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS", Version: "10.9.11" }));
expect(await p.testConnection()).toEqual({ ok: true, serverName: "NAS", version: "10.9.11" });
const bad = new JellyfinProvider();
bad.configure(cfg({ apiKey: "", userId: "" }));
const res = await bad.testConnection();
expect(res.ok).toBe(false);
expect(res.error).toContain("apiKey");
});
it("configure drops the cached token when credentials change, keeps deviceId", () => {
const p = new JellyfinProvider();
p.configure(cfg());
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
p.configure(cfg({ serverUrl: "https://other.example.com" }));
const state = JSON.parse(p.getCookie());
expect(state.accessToken).toBe("");
expect(state.deviceId).toBe("D");
});
});
+741
View File
@@ -0,0 +1,741 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
import axios, { type AxiosError, type AxiosInstance } from "axios";
import type { JellyfinConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** 1 tick = 100 ns → 10,000,000 ticks = 1 second (Jellyfin RunTimeTicks / lyric Start). */
export const TICKS_PER_SECOND = 10_000_000;
export function ticksToSeconds(ticks: number | null | undefined): number {
if (typeof ticks !== "number" || !Number.isFinite(ticks)) return 0;
return ticks / TICKS_PER_SECOND;
}
const CLIENT_NAME = "TSMusicBot";
/** Best-effort package version for the MediaBrowser auth header. */
const PKG_VERSION = (() => {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
})();
// Jellyfin quality tiers replace the NetEase labels for this source. "direct"
// (default) streams the original file — the bot re-encodes to Opus anyway, so
// this is max quality. The transcode tiers exist for remote/low-bandwidth
// Jellyfin servers.
export const JELLYFIN_QUALITY_LEVELS = [
{ value: "direct", label: "原始直传 Direct", bitrate: 0 },
{ value: "320", label: "320kbps 转码", bitrate: 320 },
{ value: "192", label: "192kbps 转码", bitrate: 192 },
{ value: "128", label: "128kbps 转码", bitrate: 128 },
] as const;
/** Subset of Jellyfin's BaseItemDto that this provider consumes. */
export interface JellyfinItem {
Id: string;
Name?: string;
Artists?: string[];
AlbumArtist?: string;
Album?: string;
AlbumId?: string;
AlbumPrimaryImageTag?: string;
RunTimeTicks?: number;
ImageTags?: { Primary?: string };
ChildCount?: number;
SongCount?: number;
Overview?: string;
}
/**
* Primary cover art URL for an item; falls back to the parent album's primary
* image (AlbumPrimaryImageTag) and finally to "" (the UI renders a placeholder
* for missing art). The api_key query param authenticates browser <img> loads
* and the TS-avatar download alike.
*/
export function buildCoverUrl(baseUrl: string, apiKey: string, item: JellyfinItem): string {
if (!baseUrl) return "";
const key = apiKey ? `&api_key=${encodeURIComponent(apiKey)}` : "";
if (item.ImageTags?.Primary) {
return `${baseUrl}/Items/${item.Id}/Images/Primary?maxWidth=512&tag=${item.ImageTags.Primary}${key}`;
}
if (item.AlbumId && item.AlbumPrimaryImageTag) {
return `${baseUrl}/Items/${item.AlbumId}/Images/Primary?maxWidth=512&tag=${item.AlbumPrimaryImageTag}${key}`;
}
return "";
}
/** Direct (untranscoded) stream URL — the default playback path. */
export function buildStreamUrl(baseUrl: string, apiKey: string, itemId: string): string {
const params = new URLSearchParams({ static: "true", api_key: apiKey });
return `${baseUrl}/Audio/${itemId}/stream?${params.toString()}`;
}
/** Server-side transcode URL for the 320/192/128 kbps tiers. */
export function buildUniversalUrl(
baseUrl: string,
o: { apiKey: string; userId: string; deviceId: string; itemId: string; kbps: number },
): string {
const params = new URLSearchParams({
userId: o.userId,
deviceId: o.deviceId,
api_key: o.apiKey,
maxStreamingBitrate: String(o.kbps * 1000),
container: "mp3,aac,m4a|aac,flac,webma,webm,wav,ogg",
transcodingContainer: "mp3",
transcodingProtocol: "http",
});
return `${baseUrl}/Audio/${o.itemId}/universal?${params.toString()}`;
}
export function mapJellyfinSong(item: JellyfinItem, coverUrl: string): Song {
return {
id: String(item.Id),
name: item.Name ?? "",
artist: (item.Artists?.length ? item.Artists : [item.AlbumArtist ?? ""])
.filter(Boolean)
.join(" / "),
album: item.Album ?? "",
duration: Math.round(ticksToSeconds(item.RunTimeTicks)),
coverUrl,
platform: "jellyfin",
};
}
export function mapJellyfinAlbum(item: JellyfinItem, coverUrl: string): Album {
return {
id: String(item.Id),
name: item.Name ?? "",
artist: item.AlbumArtist ?? (item.Artists ?? []).join(" / "),
coverUrl,
songCount: item.ChildCount ?? item.SongCount ?? 0,
platform: "jellyfin",
};
}
export function mapJellyfinPlaylist(item: JellyfinItem, coverUrl: string): Playlist {
return {
id: String(item.Id),
name: item.Name ?? "",
coverUrl,
songCount: item.ChildCount ?? item.SongCount ?? 0,
platform: "jellyfin",
};
}
/**
* Map `GET /Audio/{id}/Lyrics` → the bot's synced-lyric format. `Start` is in
* ticks; entries without Start (plain-text lyrics) collapse to time 0 so the
* lyric view still renders them, just without sync. Translation stays empty —
* Jellyfin has no translated-lyrics concept.
*/
export function mapJellyfinLyrics(payload: unknown): LyricLine[] {
const lyrics = (payload as { Lyrics?: { Text?: string; Start?: number }[] })?.Lyrics;
if (!Array.isArray(lyrics)) return [];
return lyrics
.filter((l) => typeof l?.Text === "string" && l.Text.trim() !== "")
.map((l) => ({ time: ticksToSeconds(l.Start), text: l.Text!.trim() }))
.sort((a, b) => a.time - b.time);
}
/** Operator-friendly connection/auth error description (test-connection UI + logs). */
export function describeJellyfinError(err: unknown): string {
const e = err as AxiosError;
if (e?.response) {
const s = e.response.status;
if (s === 401) return "认证失败:用户名/密码或 API Key 不正确 (401 Unauthorized)";
if (s === 403) return "该账号没有访问权限 (403 Forbidden)";
if (s === 404) return "接口不存在——请确认地址指向 Jellyfin 根路径 (404 Not Found)";
return `Jellyfin 返回 HTTP ${s}`;
}
const code = (e as { code?: string })?.code;
if (code === "ECONNREFUSED") return "无法连接 Jellyfin 服务器 (connection refused)";
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "无法解析服务器地址 (DNS lookup failed)";
if (code === "ETIMEDOUT" || code === "ECONNABORTED") return "连接 Jellyfin 超时 (timeout)";
return (err as Error)?.message ?? String(err);
}
/**
* Per-bot playback reporting handle. One playback session (PlaySessionId) per
* track start; the previous session is closed with its last known position so
* Jellyfin's played/PlayCount bookkeeping sees natural track ends as ~complete
* plays and mid-track skips as partial ones. Every call is fire-and-forget —
* reporting must never affect playback.
*/
export interface JellyfinPlaybackReporter {
onTrackStart(itemId: string): void;
onTick(itemId: string, positionSec: number, paused: boolean): void;
onStop(): void;
}
interface PersistedAuth {
accessToken?: string;
userId?: string;
deviceId?: string;
}
function emptyConfig(): JellyfinConfig {
return { serverUrl: "", authMode: "userpass", username: "", password: "", apiKey: "", userId: "" };
}
export class JellyfinProvider implements MusicProvider {
readonly platform = "jellyfin" as const;
private api: AxiosInstance;
private cfg: JellyfinConfig = emptyConfig();
private token = "";
private userId = "";
private deviceId = "";
private quality: string = "direct";
private loginPromise: Promise<void> | null = null;
private persistFn: ((serialized: string) => void) | null = null;
private logger: Logger | null = null;
constructor(logger?: Logger) {
this.api = axios.create({ timeout: 10000 });
this.logger = logger ?? null;
}
/**
* Apply (or re-apply, on settings save) the admin-configured connection.
* Changing any credential-relevant field drops the cached token so the next
* request re-authenticates; the stable deviceId survives reconfiguration.
*/
configure(cfg: JellyfinConfig): void {
const prev = this.cfg;
this.cfg = { ...cfg, serverUrl: (cfg.serverUrl ?? "").trim().replace(/\/+$/, "") };
const credsChanged =
prev.serverUrl !== this.cfg.serverUrl ||
prev.authMode !== this.cfg.authMode ||
prev.username !== this.cfg.username ||
prev.password !== this.cfg.password ||
prev.apiKey !== this.cfg.apiKey ||
prev.userId !== this.cfg.userId;
if (credsChanged) {
this.token = "";
this.userId = "";
}
}
isConfigured(): boolean {
return this.cfg.serverUrl.length > 0;
}
/** Wire the on-disk persistence used after a successful login (cookie store). */
setPersist(fn: (serialized: string) => void): void {
this.persistFn = fn;
}
setQuality(quality: string): void {
// Ignore foreign values: the legacy platform-less POST /api/music/quality
// broadcasts NetEase levels to every provider, which must not clobber the
// jellyfin default ("direct").
if (JELLYFIN_QUALITY_LEVELS.some((l) => l.value === quality)) {
this.quality = quality;
}
}
getQuality(): string {
return this.quality;
}
// --- Auth ---
private authHeader(): string {
return (
`MediaBrowser Client="${CLIENT_NAME}", Device="${CLIENT_NAME}", ` +
`DeviceId="${this.deviceId}", Version="${PKG_VERSION}"`
);
}
private ensureDeviceId(): void {
if (!this.deviceId) {
this.deviceId = crypto.randomUUID();
this.persistState();
}
}
private persistState(): void {
try {
this.persistFn?.(this.getCookie());
} catch (err) {
this.logger?.warn({ err }, "Failed to persist Jellyfin auth state");
}
}
private async login(): Promise<void> {
this.ensureDeviceId();
const res = await this.api.post(
`${this.cfg.serverUrl}/Users/AuthenticateByName`,
{ Username: this.cfg.username, Pw: this.cfg.password },
{ headers: { Authorization: this.authHeader() } },
);
const token = res.data?.AccessToken as string | undefined;
const userId = res.data?.User?.Id as string | undefined;
if (!token || !userId) {
throw new Error("Jellyfin 登录响应缺少 AccessToken/User.Id");
}
this.token = token;
this.userId = userId;
this.persistState();
this.logger?.info({ userId }, "Jellyfin authenticated");
}
private async ensureAuth(): Promise<void> {
if (!this.cfg.serverUrl) {
throw new Error("Jellyfin 未配置服务器地址 (server URL not configured)");
}
if (this.cfg.authMode === "apikey") {
if (!this.cfg.apiKey || !this.cfg.userId) {
throw new Error("Jellyfin API Key 模式需要 apiKey 和 userId");
}
this.token = this.cfg.apiKey;
this.userId = this.cfg.userId;
this.ensureDeviceId();
return;
}
if (this.token && this.userId) return;
if (!this.cfg.username) {
throw new Error("Jellyfin 未配置用户名/密码 (username not configured)");
}
if (!this.loginPromise) {
this.loginPromise = this.login().finally(() => {
this.loginPromise = null;
});
}
return this.loginPromise;
}
/** Authenticated request with a single re-auth retry on 401 (userpass mode). */
private async request<T = unknown>(
method: "get" | "post",
path: string,
opts: { params?: Record<string, unknown>; data?: unknown; retried?: boolean } = {},
): Promise<T> {
await this.ensureAuth();
// Callers snapshot `userId: this.userId` while building params, which is
// still empty before the first login. ensureAuth() has populated it by
// now, so backfill the stale-empty snapshot instead of sending userId="".
const params = opts.params ? { ...opts.params } : undefined;
if (params && "userId" in params && !params.userId) params.userId = this.userId;
try {
const res = await this.api.request<T>({
method,
url: this.cfg.serverUrl + path,
params,
data: opts.data,
headers: { "X-Emby-Token": this.token },
});
return res.data;
} catch (err) {
const status = (err as AxiosError).response?.status;
if (status === 401 && this.cfg.authMode === "userpass" && !opts.retried) {
this.token = "";
this.userId = "";
return this.request(method, path, { ...opts, retried: true });
}
throw err;
}
}
// --- MusicProvider ---
private coverFor(item: JellyfinItem): string {
return buildCoverUrl(this.cfg.serverUrl, this.token, item);
}
private mapSongs(items: JellyfinItem[] | undefined | null): Song[] {
return (items ?? []).map((i) => mapJellyfinSong(i, this.coverFor(i)));
}
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const common = {
searchTerm: query,
Recursive: true,
Limit: limit,
StartIndex: offset,
userId: this.userId,
};
const [songRes, albumRes, playlistRes] = await Promise.all([
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "Audio" },
}),
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "MusicAlbum", Fields: "ChildCount" },
}),
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "Playlist", Fields: "ChildCount" },
}),
]);
return {
songs: this.mapSongs(songRes?.Items),
albums: (albumRes?.Items ?? []).map((i) => mapJellyfinAlbum(i, this.coverFor(i))),
playlists: (playlistRes?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i))),
};
}
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
await this.ensureAuth();
const level = quality ?? this.quality;
const tier = JELLYFIN_QUALITY_LEVELS.find((l) => l.value === level);
if (!tier || tier.value === "direct") {
return { url: buildStreamUrl(this.cfg.serverUrl, this.token, songId) };
}
return {
url: buildUniversalUrl(this.cfg.serverUrl, {
apiKey: this.token,
userId: this.userId,
deviceId: this.deviceId,
itemId: songId,
kbps: tier.bitrate,
}),
};
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ids: songId, userId: this.userId },
});
const item = res?.Items?.[0];
return item ? mapJellyfinSong(item, this.coverFor(item)) : null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>(
"get",
`/Playlists/${playlistId}/Items`,
{ params: { userId: this.userId } },
);
return this.mapSongs(res?.Items);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
parentId: albumId,
sortBy: "ParentIndexNumber,IndexNumber",
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return this.getUserPlaylists();
}
async getUserPlaylists(): Promise<Playlist[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Playlist",
Recursive: true,
Fields: "ChildCount",
SortBy: "SortName",
userId: this.userId,
},
});
return (res?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i)));
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ids: playlistId, Fields: "ChildCount,Overview", userId: this.userId },
});
const item = res?.Items?.[0];
if (!item) return null;
return {
id: String(item.Id),
name: item.Name ?? "",
description: item.Overview ?? "",
coverUrl: this.coverFor(item),
songCount: item.ChildCount ?? 0,
};
}
async getLyrics(songId: string): Promise<LyricLine[]> {
try {
const res = await this.request<unknown>("get", `/Audio/${songId}/Lyrics`);
return mapJellyfinLyrics(res);
} catch (err) {
// 404 = the track simply has no lyrics — not an error.
if ((err as AxiosError).response?.status === 404) return [];
throw err;
}
}
// No QR-code login concept — connection is global and admin-configured.
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(cookie: string): void {
try {
const parsed = JSON.parse(cookie) as PersistedAuth;
if (parsed && typeof parsed === "object") {
if (typeof parsed.accessToken === "string") this.token = parsed.accessToken;
if (typeof parsed.userId === "string") this.userId = parsed.userId;
if (typeof parsed.deviceId === "string") this.deviceId = parsed.deviceId;
}
} catch {
// Not the JSON blob we persist — ignore rather than corrupt auth state.
}
}
getCookie(): string {
return JSON.stringify({
accessToken: this.token,
userId: this.userId,
deviceId: this.deviceId,
});
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cfg.serverUrl) return { loggedIn: false };
try {
const info = await this.request<{ ServerName?: string }>("get", "/System/Info");
const who = this.cfg.authMode === "apikey" ? "API Key" : this.cfg.username;
return { loggedIn: true, nickname: `${who}@${info?.ServerName ?? "Jellyfin"}` };
} catch (err) {
this.logger?.debug({ err: describeJellyfinError(err) }, "Jellyfin auth status check failed");
return { loggedIn: false };
}
}
/**
* Authenticated round-trip to /System/Info for the Settings "test connection"
* button. `candidate` (when given) tests form values in a throwaway instance
* so a failed test never disturbs the live token state.
*/
async testConnection(
candidate?: JellyfinConfig,
): Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }> {
const probe = candidate ? new JellyfinProvider() : this;
if (candidate) probe.configure(candidate);
try {
const info = await probe.request<{ ServerName?: string; Version?: string }>(
"get",
"/System/Info",
);
return { ok: true, serverName: info?.ServerName, version: info?.Version };
} catch (err) {
return { ok: false, error: describeJellyfinError(err) };
}
}
/**
* Personal FM = Instant Mix seeded from a random favorite, falling back to a
* random recently-played track, then a random library track. A final safety
* net returns 50 random tracks when Instant Mix itself yields nothing.
*/
async getPersonalFm(): Promise<Song[]> {
const seed = await this.pickFmSeed();
if (seed) {
const mix = await this.request<{ Items?: JellyfinItem[] }>(
"get",
`/Items/${seed}/InstantMix`,
{ params: { userId: this.userId, limit: 50 } },
);
const songs = this.mapSongs(mix?.Items);
if (songs.length > 0) return songs;
}
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 50,
userId: this.userId,
},
});
return this.mapSongs(random?.Items);
}
private async pickFmSeed(): Promise<string | null> {
const favorite = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsFavorite",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 1,
userId: this.userId,
},
});
if (favorite?.Items?.[0]?.Id) return favorite.Items[0].Id;
const played = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsPlayed",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "DatePlayed",
SortOrder: "Descending",
Limit: 20,
userId: this.userId,
},
});
const playedItems = played?.Items ?? [];
if (playedItems.length > 0) {
return playedItems[Math.floor(Math.random() * playedItems.length)].Id;
}
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 1,
userId: this.userId,
},
});
return random?.Items?.[0]?.Id ?? null;
}
// --- Home sections ---
async getLatestAlbums(limit = 12): Promise<Album[]> {
await this.ensureAuth();
const items = await this.request<JellyfinItem[]>(
"get",
`/Users/${this.userId}/Items/Latest`,
{ params: { IncludeItemTypes: "MusicAlbum", Limit: limit, Fields: "ChildCount" } },
);
return (Array.isArray(items) ? items : []).map((i) =>
mapJellyfinAlbum(i, this.coverFor(i)),
);
}
async getMostPlayed(limit = 12): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
SortBy: "PlayCount",
SortOrder: "Descending",
IncludeItemTypes: "Audio",
Recursive: true,
Filters: "IsPlayed",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getFavoriteSongs(limit = 100): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsFavorite",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "SortName",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getGenres(limit = 30): Promise<{ id: string; name: string }[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/MusicGenres", {
params: { userId: this.userId, Limit: limit, SortBy: "SortName" },
});
return (res?.Items ?? []).map((i) => ({ id: String(i.Id), name: i.Name ?? "" }));
}
async getGenreSongs(genreId: string, limit = 100): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
GenreIds: genreId,
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "AlbumArtist,Album,SortName",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
// --- Playback reporting (Sessions API) ---
private async report(path: string, body: Record<string, unknown>): Promise<void> {
try {
await this.request("post", path, { data: body });
} catch (err) {
// Reporting must never affect playback — log and swallow.
this.logger?.debug(
{ err: describeJellyfinError(err), path },
"Jellyfin playback report failed",
);
}
}
createPlaybackReporter(): JellyfinPlaybackReporter {
// One session per track start; remember the last reported position so the
// implicit stop on track change carries a sane PositionTicks (a natural
// track end reports ~full duration → Jellyfin counts the play).
let current: { itemId: string; sessionId: string; lastPosSec: number } | null = null;
const report = this.report.bind(this);
return {
onTrackStart(itemId: string): void {
if (current) {
void report("/Sessions/Playing/Stopped", {
ItemId: current.itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
});
}
current = { itemId, sessionId: crypto.randomUUID(), lastPosSec: 0 };
void report("/Sessions/Playing", {
ItemId: itemId,
PlaySessionId: current.sessionId,
PositionTicks: 0,
CanSeek: true,
});
},
onTick(itemId: string, positionSec: number, paused: boolean): void {
if (!current || current.itemId !== itemId) return;
current.lastPosSec = positionSec;
void report("/Sessions/Playing/Progress", {
ItemId: itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(positionSec * TICKS_PER_SECOND),
IsPaused: paused,
});
},
onStop(): void {
if (!current) return;
void report("/Sessions/Playing/Stopped", {
ItemId: current.itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
});
current = null;
},
};
}
}
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => {
it("maps the mobile-search song shape to a Song with platform 'kugou'", () => {
// Shape captured live from mobilecdn.kugou.com/api/v3/search/song.
const raw = {
hash: "B3A52A7A958BF0AED0EBFBA2E9A818B7",
album_audio_id: 32100650,
album_id: "966846",
songname: "晴天",
singername: "周杰伦",
duration: 269,
};
const [song] = mapKugouSongs([raw]);
expect(song.platform).toBe("kugou");
expect(song.name).toBe("晴天");
expect(song.artist).toBe("周杰伦");
expect(song.duration).toBe(269);
// The id must round-trip hash + album_audio_id + album_id so getSongUrl
// can resolve a stream from a search result.
expect(song.id).toBe("b3a52a7a958bf0aed0ebfba2e9a818b7|32100650|966846");
});
it("treats nested audio_info durations as milliseconds and flat search durations as seconds", () => {
// List endpoints: ms under audio_info.
expect(mapKugouSong({ audio_info: { hash: "abc", duration: 215000 } }).duration).toBe(215);
// Search endpoint: a long-form track's flat `duration` stays seconds (no /1000).
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 10800 }).duration).toBe(10800);
});
it("falls back to splitting '歌手 - 歌名' from the filename", () => {
const song = mapKugouSong({ FileHash: "deadbeef", filename: "周杰伦 - 稻香", Duration: 200 });
expect(song.artist).toBe("周杰伦");
expect(song.name).toBe("稻香");
});
it("uses PascalCase fields from the gateway shape and skips entries with no hash", () => {
const songs = mapKugouSongs([
{ FileHash: "aa", SongName: "n", SingerName: "s", MixSongID: 1, AlbumID: 2 },
{ songname: "no hash" } as any,
]);
expect(songs).toHaveLength(1);
expect(songs[0].id).toBe("aa|1|2");
});
it("returns [] for non-array input", () => {
expect(mapKugouSongs(undefined)).toEqual([]);
});
it("maps the NESTED album/playlist track shape (base + audio_info)", () => {
// Shape captured live from /album/songs (叶惠美).
const raw = {
base: { album_id: 966846, album_audio_id: 32100648, audio_name: "以父之名", author_name: "周杰伦" },
audio_info: { hash: "DBC0207490EB51153EF933EF5A7E98E4", duration: 342047 },
};
const [song] = mapKugouSongs([raw]);
expect(song.name).toBe("以父之名");
expect(song.artist).toBe("周杰伦");
expect(song.duration).toBe(342); // ms → s
expect(song.id).toBe("dbc0207490eb51153ef933ef5a7e98e4|32100648|966846");
});
});
describe("krcToLrc", () => {
it("converts KRC [startMs,durMs] line timestamps + strips word timings into parseable LRC", () => {
// Shape captured live from lyrics.kugou.com (周杰伦 - 晴天).
const krc = [
"[ti:晴天]",
"[ar:周杰伦]",
"[0,2250]<0,160,0>晴<160,160,0>天",
"[63000,1800]<0,200,0>故<200,200,0>事",
].join("\n");
const lrc = krcToLrc(krc);
expect(lrc).toContain("[00:00.00]晴天");
expect(lrc).toContain("[01:03.00]故事");
expect(lrc).not.toMatch(/<\d+,\d+,\d+>/); // word timings stripped
// And the shared LRC parser must now actually produce timed lines.
const lines = parseLyrics(lrc);
expect(lines.length).toBe(2);
expect(lines[0]).toEqual({ time: 0, text: "晴天" });
expect(lines[1].time).toBe(63);
expect(lines[1].text).toBe("故事");
});
});
describe("mapKugouAlbums", () => {
it("maps album shape and normalises {size} covers to https", () => {
const [album] = mapKugouAlbums([
{ album_id: 966846, album_name: "叶惠美", singername: "周杰伦", sizable_cover: "http://imge.kugou.com/x/{size}/abc.jpg", songcount: 11 },
]);
expect(album.platform).toBe("kugou");
expect(album.id).toBe("966846");
expect(album.name).toBe("叶惠美");
expect(album.coverUrl).toBe("https://imge.kugou.com/x/240/abc.jpg");
expect(album.songCount).toBe(11);
});
});
describe("mapKugouSong — playlist (get_other_list_file) shape", () => {
it("splits the combined `name` and uses `mixsongid` as the audio id", () => {
// Shape captured live from /pubsongs/v2/get_other_list_file_nofilt (我喜欢).
// The combined "歌手 - 歌名" is in `name`; there is no separate songname.
const raw = {
name: "KOKIA - ありがとう… (谢谢…)",
hash: "E9A08A98614DD992F11A68A5E5F1C79F",
album_id: "1491689",
mixsongid: 37533796,
timelen: 248528, // ms
cover: "http://imge.kugou.com/stdmusic/{size}/20210113/x.jpg",
};
const song = mapKugouSong(raw);
expect(song.artist).toBe("KOKIA");
expect(song.name).toBe("ありがとう… (谢谢…)");
// hash lowercased | mixsongid | album_id — so getSongUrl can resolve it.
expect(song.id).toBe("e9a08a98614dd992f11a68a5e5f1c79f|37533796|1491689");
expect(song.duration).toBe(249); // timelen ms → s
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/20210113/x.jpg");
});
});
describe("mapKugouSong — cover art per endpoint", () => {
it("reads `sizable_cover` (daily/FM shape) and resolves {size}→240, http→https", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
sizable_cover: "http://imge.kugou.com/stdmusic/{size}/y.jpg",
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/y.jpg");
});
it("falls back to `trans_param.union_cover` (search shape, no top-level cover)", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
});
it("returns an empty coverUrl when no cover field is present", () => {
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 200 }).coverUrl).toBe("");
});
it("skips an empty-string cover field (Kugou returns '') and uses the next non-empty source", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
sizable_cover: "", // present but empty — must NOT mask the real cover below
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
});
});
describe("mapKugouPlaylists", () => {
it("maps the user-playlist shape (/v7/get_all_list info), keying id on global_collection_id", () => {
// Shape captured live from /v7/get_all_list (我喜欢).
const [pl] = mapKugouPlaylists([
{ name: "我喜欢", count: 7, global_collection_id: "collection_3_2526507197_2_0", listid: 2, type: 0 },
]);
expect(pl.platform).toBe("kugou");
expect(pl.name).toBe("我喜欢");
expect(pl.songCount).toBe(7);
// Must be the global_collection_id — the only id getPlaylistSongs can open.
expect(pl.id).toBe("collection_3_2526507197_2_0");
});
it("maps the recommend shape (/v2/special_recommend), preferring global_collection_id over specialid", () => {
// Shape captured live from /v2/special_recommend special_list.
const pl = mapKugouPlaylist({
specialname: "米津玄师:蒙着眼睛也能炸翻全场",
specialid: 1154672,
global_collection_id: "collection_1_1029965246_1154672_0",
pic: "http://imge.kugou.com/specialimg/{size}/a.jpg",
percount: 0,
});
expect(pl.id).toBe("collection_1_1029965246_1154672_0");
expect(pl.name).toBe("米津玄师:蒙着眼睛也能炸翻全场");
expect(pl.coverUrl).toBe("https://imge.kugou.com/specialimg/240/a.jpg");
});
it("drops entries whose only id is a non-openable specialid/listid (not a global_collection_id)", () => {
// getPlaylistSongs can only open a global_collection_id, so a numeric
// specialid/listid would be a dead id — such entries must be dropped.
expect(mapKugouPlaylists([{ specialname: "x", specialid: 1154672, listid: 9 }])).toHaveLength(0);
expect(mapKugouPlaylists([{ name: "no id" }])).toHaveLength(0);
expect(mapKugouPlaylists(undefined)).toEqual([]);
});
});
describe("KugouProvider.search pagination", () => {
function mockProvider() {
const p = new KugouProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
(p as any).mobileHttp = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls[0];
expect(call, "expected a mobile search call").toBeTruthy();
return call[1].params as Record<string, unknown>;
}
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe(2);
expect(params.pagesize).toBe(20);
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe(1);
});
});
+1031
View File
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
// teardown is unaffected by the simulated lock on *.mp4.
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* #149: when the audio track is extracted successfully but the source video
* cannot be deleted (Windows keeps files locked briefly — rmSync with
* force:true still throws EBUSY/EPERM), the record must fall back to the
* ORIGINAL container completely: both the path AND the recorded size.
*
* Committing the size before the delete succeeded would leave the record
* claiming the small extracted size while still holding the whole video, so
* totalBytes() under-counts and the upload directory grows past its quota.
*
* This lives in its own file because it partially mocks node:fs, which would
* otherwise leak into every other test in local.test.ts.
*/
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: actual,
rmSync: (path: string, opts?: object) => {
// Simulate the lock on the source video only; every other delete
// (the discarded .mka, temp dirs, the reject path) behaves normally.
if (typeof path === "string" && path.endsWith(".mp4")) {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return actual.rmSync(path, opts as never);
},
};
});
const { LocalMusicProvider } = await import("./local.js");
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
afterEach(() => {
// Recursive teardown without rmSync (mocked above for *.mp4).
for (const f of readdirSync(dir)) {
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
}
try { rmdirSync(dir); } catch { /* best effort */ }
});
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
const src = join(dir, "fixture.mp4");
const r = spawnSync(ffmpeg!, [
"-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
"-f", "lavfi", "-i", "sine=f=440:d=3",
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
], { stdio: "ignore" });
expect(r.status).toBe(0);
const bytes = readFileSync(src);
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
});
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// Fell back to the original container — the extract was discarded.
expect(resolved!.url.endsWith(".mp4")).toBe(true);
expect(existsSync(resolved!.url)).toBe(true);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
const onDisk = statSync(resolved!.url).size;
expect(onDisk).toBe(bytes.length);
// The RECORDED size drives the quota (totalBytes()), so it must describe
// the file actually retained. It is not exposed through search()/toSong,
// but it is persisted to index.json — read it back from there.
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
id: string; size: number; filePath: string;
}>).find((r) => r.id === song.id);
expect(record).toBeDefined();
expect(record!.filePath.endsWith(".mp4")).toBe(true);
// Before the fix this was the (much smaller) .mka size while the whole
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
expect(record!.size).toBe(bytes.length);
// Sanity: the extract really is much smaller, so a wrong commit order
// would have been clearly observable rather than a rounding error.
expect(onDisk).toBeGreaterThan(50_000);
}, 60000);
});
+271 -2
View File
@@ -1,8 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { buildFfmpegArgs } from "../audio/player.js";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
import { LocalMusicProvider, parseMediaProbe } from "./local.js";
let dir: string;
@@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => {
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
// #149: video containers are accepted; only their audio track is kept.
it("still rejects a non-media extension after video was allowed", async () => {
const p = new LocalMusicProvider(dir);
for (const name of ["evil.exe", "evil.html", "evil.mp4.txt", "notes.pdf"]) {
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: name, mimeType: "video/mp4" }),
).rejects.toThrow();
}
});
it("accepts every supported video extension at the extension gate", async () => {
const p = new LocalMusicProvider(dir);
// Junk content: ffmpeg cannot open it, so it is "unrecognised" rather than
// "no audio track" and must be accepted exactly like a truncated .mp3
// always has been. The extension allowlist is what is under test here.
// .m4v is excluded on purpose — see the next test.
for (const ext of [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".ts", ".m2ts", ".ogv"]) {
const song = await p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: `clip${ext}`,
mimeType: "video/mp4",
});
expect(song.platform).toBe("local");
expect(song.name).toBe("clip");
}
});
it("refuses a .m4v raw video elementary stream, which by definition has no audio", async () => {
// .m4v is not a container — ffmpeg's rawvideo demuxer opens arbitrary
// bytes as an MPEG-4 video elementary stream, so it IS recognised and
// genuinely carries no audio track. Refusing it is the correct outcome,
// and it is the one case that distinguishes `recognized` from `probed`.
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: "clip.m4v",
mimeType: "video/x-m4v",
}),
).rejects.toThrow(/音轨/);
});
it("the error message names both audio and video formats", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: "a.exe" }),
).rejects.toThrow(/视频/);
});
});
describe("parseMediaProbe (#149)", () => {
const mp4Banner = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':
Duration: 00:03:27.15, start: 0.000000, bitrate: 1105 kb/s
Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1280x720, 30 fps
Stream #0:1[0x2](und): Audio: aac (LC), 48000 Hz, stereo, fltp, 192 kb/s`;
it("reads duration and detects the audio stream in a video container", () => {
const r = parseMediaProbe(mp4Banner);
expect(r.durationSeconds).toBe(3 * 60 + 27);
expect(r.hasAudio).toBe(true);
});
it("reports hasAudio false for a video with only a video stream", () => {
const silent = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'silent.mp4':
Duration: 00:00:02.00, start: 0.000000, bitrate: 29 kb/s
Stream #0:0[0x1](und): Video: h264 (High 4:4:4 Predictive), yuv444p, 160x120, 10 fps`;
const r = parseMediaProbe(silent);
expect(r.durationSeconds).toBe(2);
expect(r.hasAudio).toBe(false);
});
it("detects a plain audio file", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'a.mp3':
Duration: 00:00:30.02, start: 0.000000, bitrate: 128 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s`);
expect(r.durationSeconds).toBe(30);
expect(r.hasAudio).toBe(true);
});
it("does not mistake an attached cover image for an audio stream", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'cover.mp3':
Duration: 00:00:10.00, start: 0.000000, bitrate: 130 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 100x100 [attached pic]`);
expect(r.hasAudio).toBe(true);
});
it("returns zeros on unparseable output rather than throwing", () => {
const r = parseMediaProbe("ffmpeg: command exploded");
expect(r.durationSeconds).toBe(0);
expect(r.hasAudio).toBe(false);
expect(r.recognized).toBe(false);
});
// The distinction that decides whether an upload is refused: ffmpeg opened
// the file and found no audio (refuse) vs ffmpeg could not open it at all
// (accept, as it always has for truncated audio).
it("marks a readable container recognized and unreadable bytes not", () => {
expect(parseMediaProbe(mp4Banner).recognized).toBe(true);
expect(parseMediaProbe(`[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found
[in#0 @ 0x2] Error opening input: Invalid data found when processing input
Error opening input file junk.mp4.`).recognized).toBe(false);
});
it("rounds fractional durations", () => {
expect(parseMediaProbe("Duration: 00:00:03.60,").durationSeconds).toBe(4);
expect(parseMediaProbe("Duration: 01:02:03.10,").durationSeconds).toBe(3723);
});
});
describe("LocalMusicProvider quota", () => {
@@ -205,6 +317,20 @@ describe("LocalMusicProvider quota", () => {
});
});
describe("LocalMusicProvider search pagination", () => {
it("slices [offset, offset+limit) instead of the first page", async () => {
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
seed(recs); // newest-first order preserved: a, b, c, d
const p = new LocalMusicProvider(dir);
const page1 = await p.search("", 2); // offset defaults to 0
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
const page2 = await p.search("", 2, 2);
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
});
});
describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
@@ -219,3 +345,146 @@ describe("LocalMusicProvider filename handling", () => {
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
// #149 end-to-end: build real containers with the bundled ffmpeg and push
// them through the actual upload path. Skipped automatically if the binary is
// unavailable, so the suite still runs on a machine without it.
describe("LocalMusicProvider video upload, end to end (#149)", () => {
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
/** Render a real container into the temp dir and return its bytes. */
function render(name: string, args: string[]): Buffer {
const out = join(dir, name);
const r = spawnSync(ffmpeg!, ["-y", "-hide_banner", "-loglevel", "error", ...args, out], {
stdio: "ignore",
});
if (r.status !== 0) throw new Error(`fixture render failed: ${name}`);
const buf = readFileSync(out);
rmSync(out, { force: true }); // upload writes its own copy
return buf;
}
const withAudio = (dur: number, vcodec: string, acodec: string) => [
"-f", "lavfi", "-i", `testsrc=s=160x120:r=10:d=${dur}`,
"-f", "lavfi", "-i", `sine=f=440:d=${dur}`,
"-c:v", vcodec, "-c:a", acodec, "-shortest",
];
it.runIf(have)("accepts an mp4, reads its duration, and keeps only the audio", async () => {
const p = new LocalMusicProvider(dir);
const mp4 = render("src.mp4", withAudio(3, "libx264", "aac"));
const song = await p.uploadAudio({
buffer: mp4, originalName: "My Clip.mp4", mimeType: "video/mp4",
});
expect(song.name).toBe("My Clip");
expect(song.platform).toBe("local");
expect(song.duration).toBe(3);
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// The video container is gone; what remains is the extracted audio track.
// AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming
// edit list survives — see extractedAudioExt.
expect(resolved!.url.endsWith(".m4a")).toBe(true);
expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false);
expect(existsSync(resolved!.url)).toBe(true);
expect(statSync(resolved!.url).size).toBeGreaterThan(0);
expect(statSync(resolved!.url).size).toBeLessThan(mp4.length);
}, 60000);
it.runIf(have)("extracted audio is still decodable by the player's ffmpeg args", async () => {
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: render("src2.mp4", withAudio(2, "libx264", "aac")),
originalName: "clip.mp4",
mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const decoded = spawnSync(
ffmpeg!,
[...buildFfmpegArgs(url, 0).slice(0, -1), "-"],
{ maxBuffer: 64 * 1024 * 1024 },
);
expect(decoded.status).toBe(0);
// 2s of 48 kHz stereo s16le ≈ 384000 bytes; allow codec priming slack.
expect(decoded.stdout.length).toBeGreaterThan(300000);
}, 60000);
it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => {
// The strongest statement of "lossless": decode the audio track straight
// out of the source mp4, decode the stored extract, compare the PCM.
// A Matroska remux would NOT pass this — it loses the MP4 edit list that
// discards AAC encoder priming, so it decodes ~23 ms longer.
const p = new LocalMusicProvider(dir);
const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac"));
const sourceCopy = join(dir, "source-kept.mp4");
writeFileSync(sourceCopy, bytes);
const song = await p.uploadAudio({
buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const toPcm = (input: string, pre: string[] = []) => spawnSync(
ffmpeg!,
["-hide_banner", "-loglevel", "error", "-i", input, ...pre,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"],
{ maxBuffer: 128 * 1024 * 1024 },
);
const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]);
const fromExtract = toPcm(url);
expect(fromVideo.status).toBe(0);
expect(fromExtract.status).toBe(0);
expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length);
expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true);
}, 90000);
it.runIf(have)("refuses a video that genuinely has no audio track", async () => {
const p = new LocalMusicProvider(dir);
const silent = render("silent.mp4", [
"-f", "lavfi", "-i", "testsrc=s=160x120:r=10:d=2", "-an",
]);
await expect(
p.uploadAudio({ buffer: silent, originalName: "silent.mp4", mimeType: "video/mp4" }),
).rejects.toThrow(/音轨/);
// The rejected upload must not leave its bytes behind.
expect(readdirSync(dir).filter((f) => f.endsWith(".mp4"))).toEqual([]);
}, 60000);
it.runIf(have)("extracts losslessly from avi/mkv/flv too, not just mp4", async () => {
const p = new LocalMusicProvider(dir);
const cases: Array<[string, string[]]> = [
["a.avi", withAudio(2, "mpeg4", "libmp3lame")],
["a.mkv", withAudio(2, "libx264", "libopus")],
["a.flv", withAudio(2, "flv", "libmp3lame")],
];
for (const [name, args] of cases) {
const song = await p.uploadAudio({
buffer: render(`src-${name}`, args), originalName: name, mimeType: "video/x-msvideo",
});
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mka")).toBe(true);
expect(statSync(url).size).toBeGreaterThan(0);
}
}, 120000);
it.runIf(have)("a plain audio upload is untouched — no extraction, original extension kept", async () => {
const p = new LocalMusicProvider(dir);
const mp3 = render("src.mp3", ["-f", "lavfi", "-i", "sine=f=440:d=2", "-c:a", "libmp3lame"]);
const song = await p.uploadAudio({ buffer: mp3, originalName: "tune.mp3", mimeType: "audio/mpeg" });
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mp3")).toBe(true);
expect(statSync(url).size).toBe(mp3.length); // byte-identical, not remuxed
}, 60000);
});
+225 -26
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
@@ -34,6 +34,56 @@ const AUDIO_EXTENSIONS = new Set([
".ape",
]);
/** Video containers accepted for upload (#149). Only the audio track is ever
* used — the bot has no video output. Playback would work straight from the
* container (ffmpeg selects the audio stream), but we extract the audio on
* upload so a 200 MB clip does not sit on disk for a 3 MB song; see
* extractAudioTrack. `.webm` is deliberately absent: it is already in
* AUDIO_EXTENSIONS and both audio-only and video .webm are handled there. */
const VIDEO_EXTENSIONS = new Set([
".mp4",
".mov",
".avi",
".mkv",
".flv",
".wmv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ts",
".m2ts",
".ogv",
]);
/** Fallback container for an extracted audio track. Matroska takes
* essentially any audio codec, so `-c:a copy` works without knowing what the
* source used — no re-encode, no codec/extension table. */
const EXTRACTED_AUDIO_EXT = ".mka";
/**
* Container to remux an extracted track into, chosen by its codec.
*
* AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder
* priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list,
* and that edit list does NOT survive into Matroska — so an aac→.mka remux
* decodes ~23 ms longer than the source, with the priming samples audible at
* the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the
* track is then fractionally out of step with its own reported duration for
* no reason. Copying aac into .m4a keeps the edit list and decodes
* byte-for-byte identical to the audio inside the original video.
*
* AAC is worth special-casing because it is what mp4 / mov / m4v — the
* formats people actually upload — almost always carry.
*/
function extractedAudioExt(codec: string | null): string {
return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT;
}
function isSupportedUploadExt(ext: string): boolean {
return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext);
}
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
@@ -71,39 +121,130 @@ function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
async function probeDurationSeconds(filePath: string): Promise<number> {
export interface MediaProbe {
/** Rounded seconds, 0 when the probe failed or the container has no duration. */
durationSeconds: number;
/** True when ffmpeg reported at least one audio stream. Only meaningful
* together with `recognized` — see the comment there. */
hasAudio: boolean;
/** Lowercased codec name of the first audio stream ("aac", "mp3", "opus",
* …), or null when there is none. Picks the remux container. */
audioCodec: string | null;
/**
* True when ffmpeg actually opened the container and printed its
* `Input #0, <format>, from '...'` header.
*
* This is what separates "ffmpeg looked inside and there is genuinely no
* audio track" from "ffmpeg could not make sense of these bytes at all".
* Both produce hasAudio === false, but only the first is a file we should
* refuse. Unreadable bytes have always been accepted here (a truncated mp3
* uploads fine and simply reports duration 0), and that stays true.
*/
recognized: boolean;
/** False when ffmpeg could not be run or timed out, so nothing else in this
* object is meaningful and the caller must not reject the file on it. */
probed: boolean;
}
/** Parse `Duration: HH:MM:SS.ss`, the `Input #0,` header and
* `Stream #0:N...: Audio:` out of the banner ffmpeg prints on stderr when
* asked to open a file with no output. */
export function parseMediaProbe(stderr: string): Omit<MediaProbe, "probed"> {
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
let durationSeconds = 0;
if (match) {
const total = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
durationSeconds = Number.isFinite(total) ? Math.round(total) : 0;
}
// e.g. " Stream #0:1[0x2](und): Audio: aac (LC) ..." — the stream index and
// the bracketed id/language vary, so match on the "Audio:" tag itself. An
// embedded cover image is a separate "Video: mjpeg ... [attached pic]" line
// and never matches this.
const audioMatch = stderr.match(/Stream #\d+:\d+[^\n]*:\s*Audio:\s*([A-Za-z0-9_]+)/);
const hasAudio = audioMatch !== null;
const audioCodec = audioMatch ? audioMatch[1].toLowerCase() : null;
// "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':" — absent entirely
// when ffmpeg bails with "Error opening input: Invalid data found ...".
const recognized = /^Input #\d+,/m.test(stderr);
return { durationSeconds, hasAudio, audioCodec, recognized };
}
async function probeMedia(filePath: string): Promise<MediaProbe> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
let settled = false;
const done = (probe: MediaProbe) => {
if (settled) return;
settled = true;
resolve(probe);
};
// Video containers are much larger than the audio files this used to see,
// and the probe only reads headers — but a network/USB path can still be
// slow, so allow more than the old 5s before giving up.
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
}, 20000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
done({ ...parseMediaProbe(stderr), probed: true });
});
});
}
/**
* Remux the first audio stream of `source` into `target` (#149).
*
* `-c:a copy` — the audio is moved bit-for-bit into a Matroska audio
* container, so this is fast, lossless, and codec-agnostic. Nothing is
* re-encoded, so a 200 MB .mp4 becomes a few MB .mka with the original audio
* intact. Video, subtitle and data streams are dropped.
*
* Returns true only if ffmpeg exited 0 AND produced a non-empty file, so a
* partial/zero-byte result can never be mistaken for a successful extraction.
* Callers fall back to keeping the original container, which plays fine.
*/
async function extractAudioTrack(source: string, target: string): Promise<boolean> {
const ok = await new Promise<boolean>((resolve) => {
const ffmpeg = spawn(
ffmpegPath || "ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", source,
"-vn", "-sn", "-dn", "-map", "0:a:0", "-c:a", "copy", target],
{ stdio: ["ignore", "ignore", "ignore"] },
);
let settled = false;
const done = (v: boolean) => {
if (settled) return;
settled = true;
resolve(v);
};
// Remuxing is I/O bound, but a multi-GB input on a slow disk still takes
// a while. Cap it so a pathological file cannot wedge the upload request.
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
done(false);
}, 120000);
ffmpeg.on("error", () => { clearTimeout(timeout); done(false); });
ffmpeg.on("close", (code) => { clearTimeout(timeout); done(code === 0); });
});
if (!ok) return false;
try {
return statSync(target).size > 0;
} catch {
return false;
}
}
export class LocalMusicProvider implements MusicProvider {
readonly platform = "local" as const;
private readonly uploadDir: string;
@@ -171,33 +312,91 @@ export class LocalMusicProvider implements MusicProvider {
const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
// is one of the known audio/video types, so a spoofed header cannot
// persist an arbitrary-extension blob on disk.
if (!isSupportedUploadExt(ext)) {
throw new Error(
"只支持常见音频文件(mp3、flac、wav、m4a、ogg、opus、aac、webm 等)" +
"和视频文件(mp4、mov、avi、mkv、flv、wmv 等,仅取其中的音轨播放)",
);
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
const isVideo = VIDEO_EXTENSIONS.has(ext);
let filePath = path.join(this.uploadDir, `${id}${ext}`);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
let probe: MediaProbe;
try {
probe = await probeMedia(filePath);
} catch {
probe = { durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false };
}
// Reject a video with no audio track up front (#149). Left to playback it
// would produce a silent, zero-byte stream that just looks like a broken
// song. Require `recognized` as well as `probed`: bytes ffmpeg cannot open
// at all report hasAudio false for a different reason, and those have
// always been accepted (a truncated upload lands with duration 0) — this
// change must not start rejecting them.
if (isVideo && probe.probed && probe.recognized && !probe.hasAudio) {
rmSync(filePath, { force: true });
throw new Error("这个视频里没有音轨,无法播放");
}
let size = input.buffer.length;
if (isVideo) {
// Keep only the audio. The video bytes are dead weight against the
// upload-directory quota and would never be used.
// Preferred container first; if that remux fails (a codec the container
// will not take), retry into Matroska, which takes almost anything.
const preferredExt = extractedAudioExt(probe.audioCodec);
let extracted = path.join(this.uploadDir, `${id}${preferredExt}`);
let ok = await extractAudioTrack(filePath, extracted);
if (!ok && preferredExt !== EXTRACTED_AUDIO_EXT) {
rmSync(extracted, { force: true });
extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`);
ok = await extractAudioTrack(filePath, extracted);
}
if (ok) {
try {
// Commit filePath and size TOGETHER, and only after the source is
// actually gone. rmSync(force) still throws EBUSY/EPERM on Windows,
// and assigning size first would leave the record claiming the
// small extracted size while still pointing at the whole video —
// which makes totalBytes() under-count and lets the upload
// directory grow past its quota.
const extractedSize = statSync(extracted).size;
rmSync(filePath, { force: true });
filePath = extracted;
size = extractedSize;
} catch {
// Could not stat/remove (Windows lock) — keep playing the original
// container and drop the half-finished extract.
rmSync(extracted, { force: true });
}
} else {
// Extraction failed (exotic codec Matroska won't take, timeout, …).
// The original container still plays: ffmpeg picks its audio stream.
rmSync(extracted, { force: true });
}
}
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
duration: probe.durationSeconds,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
size,
mimeType: input.mimeType || "application/octet-stream",
};
@@ -214,12 +413,12 @@ export class LocalMusicProvider implements MusicProvider {
return song;
}
async search(query: string, limit = 20): Promise<SearchResult> {
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim().toLowerCase();
const songs = this.records
.filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit)
.slice(offset, offset + limit)
.map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] };
}
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
});
});
describe("NeteaseProvider.search pagination", () => {
function mockProvider() {
const p = new NeteaseProvider("http://x");
const get = vi.fn().mockResolvedValue({
data: { result: { songs: [], playlists: [], albums: [] } },
});
(p as any).api = { get };
return { p, get };
}
/** Find the /cloudsearch call whose params.type matches. */
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
return call![1].params as Record<string, unknown>;
}
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20);
// songs (type 1): offset forwarded, limit unchanged
const songs = callByType(get, 1);
expect(songs.limit).toBe(20);
expect(songs.offset).toBe(20);
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
const playlists = callByType(get, 1000);
expect(playlists.limit).toBe(20);
expect(playlists.offset).toBe(20);
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
const albums = callByType(get, 10);
expect(albums.limit).toBe(20);
expect(albums.offset).toBe(20);
});
it("defaults offset to 0 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(callByType(get, 1).offset).toBe(0);
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
});
+8 -4
View File
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}),
this.api.get("/cloudsearch", {
params: {
keywords: query,
type: 1000,
limit: 10,
limit,
offset,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}),
]);
+17 -5
View File
@@ -1,3 +1,15 @@
/** All music source platforms. Jellyfin ItemIds are GUID strings — never
* assume numeric ids when handling a generic Platform. */
export type Platform =
| "netease"
| "qq"
| "bilibili"
| "youtube"
| "local"
| "kugou"
| "spotify"
| "jellyfin";
export interface Song {
id: string;
name: string;
@@ -5,7 +17,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: Platform;
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
@@ -27,7 +39,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: Platform;
}
export interface PlaylistDetail {
@@ -44,7 +56,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: Platform;
}
export interface LyricLine {
@@ -72,9 +84,9 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
readonly platform: Platform;
search(query: string, limit?: number): Promise<SearchResult>;
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void;
getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
// and the per-instance api) share this single mock so the search test can
// inspect the outgoing params/body regardless of which client issued them.
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
expect(out[0].id).toBe("");
});
});
describe("QQMusicProvider.search pagination", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
/** musicu.fcg returns one song → primary path succeeds. */
function musicuOk() {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
},
};
}
return { data: {} };
});
}
function musicuReqData() {
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
expect(call, "expected a musicu.fcg call").toBeTruthy();
return JSON.parse(call![1].params.data);
}
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(2);
expect(d.req_0.param.num_per_page).toBe(20);
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
expect(d.req_album.param.page_num).toBe(2);
expect(d.req_album.param.num_per_page).toBe(20);
expect(d.req_playlist.param.page_num).toBe(2);
expect(d.req_playlist.param.num_per_page).toBe(20);
});
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20);
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(1);
});
it("fallback client_search_cp sets p to the page cursor", async () => {
// musicu returns no songs → primary returns null → fallback runs.
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
}
// client_search_cp
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
});
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const songCall = mockGet.mock.calls.find(
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
);
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
});
+67 -11
View File
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
};
}
async search(query: string, limit = 20): Promise<SearchResult> {
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
const primary = await this.searchViaMusicuFcg(query, limit, offset);
if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
return this.searchViaClientSearchCp(query, limit, offset);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
limit: number,
offset = 0
): Promise<SearchResult | null> {
try {
// num_per_page must stay >= 10 (lower values return empty). It is now
// limit-driven for ALL three lists (albums/playlists were hardcoded to
// 10). page_num is the offset cursor; the web always requests in
// limit-aligned pages so offset is a multiple of limit.
const numPerPage = Math.max(10, Math.min(limit, 50));
const pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
limit: number,
offset = 0
): Promise<SearchResult> {
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
// offset is a multiple of limit.
const page = Math.floor(offset / limit) + 1;
const songParams = {
w: query,
format: "json",
p: 1,
p: page,
n: Math.min(limit, 50),
type: 0,
cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = {
w: query,
format: "json",
p: 1,
p: page,
n: 5,
t: 8,
cr: 1,
@@ -354,8 +364,18 @@ export class QQMusicProvider implements MusicProvider {
};
}
} catch {
// fall through to stub
// fall through to musicu.fcg fallback
}
// Fallback: fetch metadata via u.y.qq.com/cgi-bin/musicu.fcg. The bundled
// library's /getSongInfo route is broken (upstream code 500001), so the
// primary try above almost always falls through here for QQ. Without this,
// play-by-id songs carry an empty name → the TS nickname / now-playing
// message renders as "♪ 正在播放: - []". This endpoint (same host/shape
// as search) reliably returns full track_info without requiring login.
const detail = await this.fetchSongDetailViaMusicu(songId);
if (detail) return detail;
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
// play URL. Name/artist/album will be empty in play history, but the
// song will actually play, which is the important part.
@@ -370,6 +390,42 @@ export class QQMusicProvider implements MusicProvider {
};
}
/** Fetch a single song's metadata via u.y.qq.com/cgi-bin/musicu.fcg
* (module music.pf_song_detail_svr / get_song_detail_yqq). This mirrors the
* search path's host + request shape and, unlike the library's /getSongInfo,
* actually works against QQ's current API. Returns null on any failure so the
* caller can fall back to the minimal stub. */
private async fetchSongDetailViaMusicu(songId: string): Promise<Song | null> {
try {
const reqData = JSON.stringify({
req_0: {
module: "music.pf_song_detail_svr",
method: "get_song_detail_yqq",
param: { song_mid: songId, song_type: 0 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const t = res.data?.req_0?.data?.track_info;
if (!t) return null;
const albumMid = t.album?.mid ?? t.album?.pmid ?? "";
return {
id: String(t.mid ?? t.id ?? songId),
name: t.name ?? t.title ?? "",
artist: (t.singer ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
album: t.album?.name ?? t.album?.title ?? "",
duration: t.interval ?? 0,
coverUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: "",
platform: "qq",
};
} catch {
return null;
}
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/getSongListDetail", {
params: { disstid: playlistId, ...this.cookieParams },
+12
View File
@@ -0,0 +1,12 @@
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"));
});
+25
View File
@@ -0,0 +1,25 @@
/** 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;
}
}
+41
View File
@@ -0,0 +1,41 @@
// src/music/spotify/backend.ts
// Type contract for the Spotify audio backend (go-librespot sidecar).
// Interface-only: this module intentionally contains NO runtime code so it
// can be imported for types by go-librespot.ts and controller.ts without
// pulling in child_process/ws/ffmpeg at type-check time.
/** Emitted when the currently playing Spotify track finishes or is stopped. */
export interface SpotifyTrackEndedEvent {
uri: string;
reason: "ended" | "stopped" | "error";
}
/** Now-playing metadata surfaced from the go-librespot "metadata" event. */
export interface SpotifyNowPlaying {
uri: string;
name: string;
artist: string;
album: string;
coverUrl: string;
durationMs: number;
}
/**
* Long-lived Spotify audio source: owns the go-librespot sidecar + FIFO->ffmpeg
* PCM pipe and exposes transport control plus a continuous 48kHz s16le stereo
* PCM stream to feed AudioPlayer.playPcmStream().
*/
export interface SpotifyAudioBackend {
start(): Promise<void>;
stop(): void;
isReady(): boolean;
playTrack(uri: string): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
getPcmStream(): import("node:stream").Readable;
getPositionMs(): number;
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;
}
+363
View File
@@ -0,0 +1,363 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import {
isGoLibrespotSupported,
pickGoLibrespotPath,
findGoLibrespot,
checkGoLibrespotAvailable,
resetGoLibrespotBinaryCache,
__setGoLibrespotVersionProbe,
isRustLibrespotSupported,
pickLibrespotPath,
findLibrespot,
checkLibrespotAvailable,
resetLibrespotBinaryCache,
__setLibrespotVersionProbe,
resolveExecutable,
isLibrespotPresent,
isGoLibrespotPresent,
} from "./binary.js";
const origPlatform = process.platform;
function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { value: p, configurable: true });
}
// Snapshot the PATH env so PATH-resolution tests can point it at temp dirs and
// restore afterwards. PATHEXT is optional (undefined on posix); track presence.
const origPath = process.env.PATH;
const origPathExt = process.env.PATHEXT;
const tmpDirs: string[] = [];
function mkTmpDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tmpDirs.push(dir);
return dir;
}
afterEach(() => {
setPlatform(origPlatform);
__setGoLibrespotVersionProbe(null);
resetGoLibrespotBinaryCache();
__setLibrespotVersionProbe(null);
resetLibrespotBinaryCache();
process.env.PATH = origPath;
if (origPathExt === undefined) delete process.env.PATHEXT;
else process.env.PATHEXT = origPathExt;
for (const dir of tmpDirs.splice(0)) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
describe("isGoLibrespotSupported", () => {
it("is true only on linux", () => {
setPlatform("linux");
expect(isGoLibrespotSupported()).toBe(true);
setPlatform("win32");
expect(isGoLibrespotSupported()).toBe(false);
setPlatform("darwin");
expect(isGoLibrespotSupported()).toBe(false);
});
});
describe("pickGoLibrespotPath (bin/ then PATH ordering)", () => {
const binPath = join("some", "root", "bin", "go-librespot");
it("prefers the bin/ path when the file exists", () => {
expect(
pickGoLibrespotPath([binPath, "go-librespot"], (p) => p === binPath),
).toBe(binPath);
});
it("falls through to the bare PATH name when the bin/ file is missing", () => {
expect(pickGoLibrespotPath([binPath, "go-librespot"], () => false)).toBe(
"go-librespot",
);
});
it("returns bare command names without touching the filesystem", () => {
const exists = vi.fn(() => false);
expect(pickGoLibrespotPath(["go-librespot"], exists)).toBe("go-librespot");
expect(exists).not.toHaveBeenCalled();
});
});
describe("findGoLibrespot", () => {
it("returns the bare command name when bin/go-librespot is absent", () => {
// No go-librespot binary is committed under bin/, so resolution must
// fall back to the bare PATH name (execFile resolves it at run time).
expect(findGoLibrespot()).toBe("go-librespot");
});
});
describe("checkGoLibrespotAvailable", () => {
it("returns false immediately on unsupported platforms without probing", async () => {
setPlatform("darwin");
const probe = vi.fn(async () => {});
__setGoLibrespotVersionProbe(probe);
expect(await checkGoLibrespotAvailable()).toBe(false);
expect(probe).not.toHaveBeenCalled();
});
it("returns true when the binary responds to --version on linux", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
});
it("caches only positive results and probes once", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setGoLibrespotVersionProbe(probe);
expect(await checkGoLibrespotAvailable()).toBe(true);
expect(await checkGoLibrespotAvailable()).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
it("does not cache a failed probe (retries on the next call)", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {
throw new Error("ENOENT");
});
expect(await checkGoLibrespotAvailable()).toBe(false);
// A later successful probe must now succeed — negatives are not cached.
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
});
it("resetGoLibrespotBinaryCache clears a cached positive", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
resetGoLibrespotBinaryCache();
__setGoLibrespotVersionProbe(async () => {
throw new Error("gone");
});
expect(await checkGoLibrespotAvailable()).toBe(false);
});
});
describe("isRustLibrespotSupported", () => {
it("is true on every platform (pipe->stdout works everywhere)", () => {
setPlatform("linux");
expect(isRustLibrespotSupported()).toBe(true);
setPlatform("win32");
expect(isRustLibrespotSupported()).toBe(true);
setPlatform("darwin");
expect(isRustLibrespotSupported()).toBe(true);
});
});
describe("pickLibrespotPath (bin/ then PATH ordering, win32 exe)", () => {
it("prefers the bin/ path when the file exists", () => {
const binPath = join("some", "root", "bin", "librespot");
expect(
pickLibrespotPath([binPath, "librespot"], (p) => p === binPath),
).toBe(binPath);
});
it("prefers the bin/librespot.exe path on win32 when it exists", () => {
setPlatform("win32");
const binExe = join("some", "root", "bin", "librespot.exe");
expect(
pickLibrespotPath([binExe, "librespot.exe"], (p) => p === binExe),
).toBe(binExe);
});
it("falls through to the bare PATH name (librespot) on posix when bin/ is missing", () => {
setPlatform("linux");
const binPath = join("some", "root", "bin", "librespot");
expect(pickLibrespotPath([binPath, "librespot"], () => false)).toBe(
"librespot",
);
});
it("falls through to librespot.exe on win32 when bin/ is missing", () => {
setPlatform("win32");
const binExe = join("some", "root", "bin", "librespot.exe");
expect(pickLibrespotPath([binExe], () => false)).toBe("librespot.exe");
});
it("returns bare command names without touching the filesystem", () => {
const exists = vi.fn(() => false);
expect(pickLibrespotPath(["librespot"], exists)).toBe("librespot");
expect(exists).not.toHaveBeenCalled();
});
});
describe("findLibrespot", () => {
it("returns the bare command name when bin/librespot is absent", () => {
// No librespot binary is committed under bin/, so resolution must fall
// back to the bare PATH name (execFile resolves it at run time).
setPlatform("linux");
expect(findLibrespot()).toBe("librespot");
});
it("returns librespot.exe on win32 when bin/librespot.exe is absent", () => {
setPlatform("win32");
expect(findLibrespot()).toBe("librespot.exe");
});
});
describe("checkLibrespotAvailable", () => {
it("returns true when the binary responds to --version (any platform)", async () => {
setPlatform("win32");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("returns true on darwin too (no platform gate)", async () => {
setPlatform("darwin");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("caches only positive results and probes once", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setLibrespotVersionProbe(probe);
expect(await checkLibrespotAvailable()).toBe(true);
expect(await checkLibrespotAvailable()).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
it("does not cache a failed probe (retries on the next call)", async () => {
setPlatform("win32");
__setLibrespotVersionProbe(async () => {
throw new Error("ENOENT");
});
expect(await checkLibrespotAvailable()).toBe(false);
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("resetLibrespotBinaryCache clears a cached positive", async () => {
setPlatform("linux");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
resetLibrespotBinaryCache();
__setLibrespotVersionProbe(async () => {
throw new Error("gone");
});
expect(await checkLibrespotAvailable()).toBe(false);
});
it("de-dupes concurrent in-flight probes", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setLibrespotVersionProbe(probe);
const [a, b] = await Promise.all([
checkLibrespotAvailable(),
checkLibrespotAvailable(),
]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
});
describe("resolveExecutable", () => {
it("returns a bin/-style path (contains a separator) that exists, unchanged", () => {
const dir = mkTmpDir("tsmb-resolve-");
const full = join(dir, "some-binary");
writeFileSync(full, "#!/bin/sh\n");
// The path contains a separator so it is checked directly, not via PATH.
expect(resolveExecutable(full)).toBe(full);
});
it("returns null for a bin/-style path that does not exist", () => {
const full = join(tmpdir(), "tsmb-resolve-missing", "nope-binary");
expect(resolveExecutable(full)).toBeNull();
});
it("resolves a BARE command name found in a PATH directory (the I3 fix)", () => {
// This is the regression the fix targets: a PATH-installed binary (bare
// name, no separator) must resolve against $PATH, not process.cwd().
const dir = mkTmpDir("tsmb-resolve-path-");
const exeName = process.platform === "win32" ? "faketool.exe" : "faketool";
const full = join(dir, exeName);
writeFileSync(full, "#!/bin/sh\n");
process.env.PATH = dir;
expect(resolveExecutable(exeName)).toBe(full);
});
it("returns null for a bare name not present in any PATH directory", () => {
process.env.PATH = mkTmpDir("tsmb-resolve-empty-");
expect(resolveExecutable("definitely-not-a-real-binary-xyz")).toBeNull();
});
it("appends PATHEXT extensions on win32 to resolve a bare (extensionless) name", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-resolve-pathext-");
const full = join(dir, "mytool.CMD");
writeFileSync(full, "@echo hi\n");
process.env.PATH = dir;
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
// Bare "mytool" has no extension; win32 resolution must try PATHEXT and
// find mytool.CMD.
expect(resolveExecutable("mytool")).toBe(full);
});
it("does not touch PATH for a bin/-style relative path with a separator", () => {
// A candidate with a separator is checked directly even if PATH is empty.
process.env.PATH = "";
const dir = mkTmpDir("tsmb-resolve-direct-");
const full = join(dir, "direct-binary");
writeFileSync(full, "#!/bin/sh\n");
expect(resolveExecutable(full)).toBe(full);
});
});
describe("isLibrespotPresent (PATH-aware, sync)", () => {
it("true when the bare librespot name resolves on PATH (posix)", () => {
setPlatform("linux");
const dir = mkTmpDir("tsmb-librespot-path-");
writeFileSync(join(dir, "librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isLibrespotPresent()).toBe(true);
});
it("true when librespot.exe resolves on PATH (win32)", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-librespot-win-");
writeFileSync(join(dir, "librespot.exe"), "MZ\n");
process.env.PATH = dir;
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
expect(isLibrespotPresent()).toBe(true);
});
it("false when librespot is not on PATH", () => {
setPlatform("linux");
process.env.PATH = mkTmpDir("tsmb-librespot-empty-");
expect(isLibrespotPresent()).toBe(false);
});
});
describe("isGoLibrespotPresent (PATH-aware, sync)", () => {
it("true when go-librespot resolves on PATH (linux)", () => {
setPlatform("linux");
const dir = mkTmpDir("tsmb-go-path-");
writeFileSync(join(dir, "go-librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isGoLibrespotPresent()).toBe(true);
});
it("false on non-linux platforms regardless of PATH (unsupported gate)", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-go-win-");
writeFileSync(join(dir, "go-librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isGoLibrespotPresent()).toBe(false);
});
it("false on linux when go-librespot is not on PATH", () => {
setPlatform("linux");
process.env.PATH = mkTmpDir("tsmb-go-empty-");
expect(isGoLibrespotPresent()).toBe(false);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, delimiter } from "node:path";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Resolve a command to an existing absolute path, SYNCHRONOUSLY. A candidate
* that already contains a path separator (a project bin/ path) is checked
* directly; a BARE command name (e.g. "librespot" / "go-librespot") is searched
* across the $PATH directories — with PATHEXT extensions appended on win32 — so
* a scoop/choco/cargo/apt PATH install resolves. Returns the resolved path, or
* null when nothing exists.
*
* This is the sync counterpart to checkLibrespotAvailable()/…GoLibrespot… and
* is what the hot-path presence gates (chooseBackend/isAvailable) rely on: a
* bare name must NOT be handed to existsSync() directly, since existsSync
* resolves it against process.cwd() rather than PATH (Bug I3/m1).
*/
export function resolveExecutable(cmd: string): string | null {
if (cmd.includes("/") || cmd.includes("\\")) {
return existsSync(cmd) ? cmd : null;
}
const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
const exts =
process.platform === "win32"
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
: [""];
for (const dir of dirs) {
for (const ext of exts) {
const hasExt = ext && cmd.toLowerCase().endsWith(ext.toLowerCase());
const full = join(dir, hasExt ? cmd : cmd + ext);
if (existsSync(full)) return full;
}
}
return null;
}
/**
* True only on Linux. go-librespot ships Linux-only release binaries and the
* sidecar relies on a POSIX FIFO (mkfifo), so the Spotify audio backend is
* gated to Linux/Docker. Everywhere else the caller falls back to the Stage-1
* sentinel-skip message.
*/
export function isGoLibrespotSupported(): boolean {
return process.platform === "linux";
}
/**
* Pure resolver core behind findGoLibrespot(). Returns the first candidate
* that is either a bare command name (left for execFile to resolve via PATH)
* or an existing bin/ file. Exported so tests can inject candidates + a fake
* existence predicate and need no real binary on disk.
*/
export function pickGoLibrespotPath(
candidates: string[],
exists: (p: string) => boolean,
): string {
for (const c of candidates) {
// bin/ paths only count when the file is actually present; bare names are
// returned unconditionally and resolved later via PATH.
const isBinPath = c.includes(join("bin", "go-librespot"));
if (!isBinPath || exists(c)) return c;
}
return "go-librespot";
}
/** Resolve the go-librespot binary path: project bin/ dir first, then PATH. */
export function findGoLibrespot(): string {
// src/music/spotify -> ../../../bin (one level deeper than youtube.ts).
const binPath = join(__dirname, "..", "..", "..", "bin", "go-librespot");
return pickGoLibrespotPath([binPath, "go-librespot"], existsSync);
}
/**
* SYNCHRONOUS presence gate for go-librespot: supported platform (linux) AND
* the resolved binary (project bin/ OR a PATH install) actually exists. This is
* what chooseBackend()/isAvailable() must use — NOT existsSync(findGoLibrespot())
* which cannot see a bare PATH name (Bug I3/m1).
*/
export function isGoLibrespotPresent(): boolean {
return isGoLibrespotSupported() && resolveExecutable(findGoLibrespot()) !== null;
}
// Injectable `--version` probe. Defaults to the real execFile call; tests
// override it so checkGoLibrespotAvailable() needs no real binary. Keeps the
// public checkGoLibrespotAvailable() signature param-free per the contract.
type VersionProbe = (bin: string) => Promise<void>;
const realProbe: VersionProbe = async (bin) => {
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
};
let versionProbe: VersionProbe = realProbe;
/** Test hook: override the `--version` probe, or restore the default with null. */
export function __setGoLibrespotVersionProbe(
probe: VersionProbe | null,
): void {
versionProbe = probe ?? realProbe;
}
/**
* Availability check for go-librespot. Returns false immediately on non-Linux
* platforms (unsupported). Otherwise runs `go-librespot --version` (5s timeout)
* and caches ONLY the positive result — a missing binary is retried on the
* next call so the operator can install it without restarting the server.
*/
let cachedAvailable = false;
let pendingCheck: Promise<boolean> | null = null;
export async function checkGoLibrespotAvailable(): Promise<boolean> {
if (!isGoLibrespotSupported()) return false;
if (cachedAvailable) return true;
if (pendingCheck) return pendingCheck;
pendingCheck = (async () => {
try {
await versionProbe(findGoLibrespot());
cachedAvailable = true;
return true;
} catch {
return false;
} finally {
pendingCheck = null;
}
})();
return pendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetGoLibrespotBinaryCache(): void {
cachedAvailable = false;
pendingCheck = null;
}
// ---------------------------------------------------------------------------
// Rust librespot (librespot-org) resolver — mirrors the go-librespot fns
// above. Unlike go-librespot, Rust librespot's `--backend pipe` writes PCM to
// *stdout* on every platform (no FIFO, no audio device), so it is supported
// on Windows/macOS/Linux alike and the binary is named librespot.exe on win32.
// ---------------------------------------------------------------------------
/**
* True on ALL platforms. The Rust librespot pipe backend writes raw bytes to
* process stdout, which Node's spawned child.stdout receives unmodified on
* Windows too — so there is no platform gate here (contrast
* isGoLibrespotSupported, which is Linux-only).
*/
export function isRustLibrespotSupported(): boolean {
return true;
}
/**
* Pure resolver core behind findLibrespot(). Returns the first candidate that
* is either a bare command name (left for execFile to resolve via PATH) or an
* existing bin/ file. Exported so tests can inject candidates + a fake
* existence predicate and need no real binary on disk. Mirrors
* pickGoLibrespotPath but keys off the win32 exe name.
*/
export function pickLibrespotPath(
candidates: string[],
exists: (p: string) => boolean,
): string {
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
for (const c of candidates) {
// bin/ paths only count when the file is actually present; bare names are
// returned unconditionally and resolved later via PATH.
const isBinPath = c.includes(join("bin", "librespot"));
if (!isBinPath || exists(c)) return c;
}
return exe;
}
/**
* Resolve the Rust librespot binary path: project bin/ dir first, then PATH.
* On win32 both the bin/librespot.exe candidate and the bare "librespot.exe"
* fallback are used so a PATH-installed librespot.exe (scoop/choco) resolves.
*/
export function findLibrespot(): string {
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
// src/music/spotify -> ../../../bin (same depth as findGoLibrespot).
const binExe = join(__dirname, "..", "..", "..", "bin", exe);
const binBare = join(__dirname, "..", "..", "..", "bin", "librespot");
return pickLibrespotPath([binExe, binBare, exe], existsSync);
}
/**
* SYNCHRONOUS presence gate for Rust librespot: supported everywhere AND the
* resolved binary (project bin/ OR a scoop/choco/cargo PATH install) actually
* exists. This is what chooseBackend()/isAvailable() must use — NOT
* existsSync(findLibrespot()) which cannot see a bare PATH name (Bug I3/m1).
*/
export function isLibrespotPresent(): boolean {
return isRustLibrespotSupported() && resolveExecutable(findLibrespot()) !== null;
}
// Injectable `--version` probe. Defaults to the real execFile call; tests
// override it so checkLibrespotAvailable() needs no real binary. Keeps the
// public checkLibrespotAvailable() signature param-free per the contract.
type LibrespotVersionProbe = (bin: string) => Promise<void>;
const realLibrespotProbe: LibrespotVersionProbe = async (bin) => {
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
};
let librespotVersionProbe: LibrespotVersionProbe = realLibrespotProbe;
/** Test hook: override the `--version` probe, or restore the default with null. */
export function __setLibrespotVersionProbe(
probe: LibrespotVersionProbe | null,
): void {
librespotVersionProbe = probe ?? realLibrespotProbe;
}
/**
* Availability check for Rust librespot. No platform gate (supported
* everywhere). Runs `librespot --version` (5s timeout) and caches ONLY the
* positive result — a missing binary is retried on the next call so the
* operator can install it (cargo/scoop/choco) without restarting the server.
*/
let rustCachedAvailable = false;
let rustPendingCheck: Promise<boolean> | null = null;
export async function checkLibrespotAvailable(): Promise<boolean> {
if (!isRustLibrespotSupported()) return false;
if (rustCachedAvailable) return true;
if (rustPendingCheck) return rustPendingCheck;
rustPendingCheck = (async () => {
try {
await librespotVersionProbe(findLibrespot());
rustCachedAvailable = true;
return true;
} catch {
return false;
} finally {
rustPendingCheck = null;
}
})();
return rustPendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetLibrespotBinaryCache(): void {
rustCachedAvailable = false;
rustPendingCheck = null;
}
+396
View File
@@ -0,0 +1,396 @@
import { describe, it, expect, vi } from "vitest";
import type { AxiosInstance } from "axios";
import { SpotifyConnectApi } from "./connect-api.js";
/** Minimal axios stub: only get/put are exercised by the Connect client. */
function makeHttp(overrides?: Partial<Record<"get" | "put", any>>) {
return {
get: vi.fn().mockResolvedValue({ status: 200, data: {} }),
put: vi.fn().mockResolvedValue({ status: 200, data: {} }),
...overrides,
} as unknown as AxiosInstance;
}
const AUTH = { headers: { Authorization: "Bearer tok123" } };
const token = () =>
vi.fn<() => Promise<string | null>>().mockResolvedValue("tok123");
describe("SpotifyConnectApi.getDevices", () => {
it("GETs /v1/me/player/devices with the bearer header and maps the list", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
devices: [
{ id: "dev-1", name: "TS Bot", is_active: true, type: "Speaker" },
{ id: "dev-2", name: "Phone", is_active: false },
],
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const devices = await api.getDevices();
expect(http.get).toHaveBeenCalledWith("/v1/me/player/devices", AUTH);
expect(devices).toEqual([
{ id: "dev-1", name: "TS Bot", is_active: true },
{ id: "dev-2", name: "Phone", is_active: false },
]);
});
it("returns [] when getToken() is null (unauthorized) without calling http", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await expect(api.getDevices()).resolves.toEqual([]);
expect(http.get).not.toHaveBeenCalled();
});
it("returns [] on a 401/network rejection (graceful)", async () => {
const err: any = new Error("unauthorized");
err.response = { status: 401 };
const http = makeHttp({ get: vi.fn().mockRejectedValue(err) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getDevices()).resolves.toEqual([]);
});
});
describe("SpotifyConnectApi.findDeviceByName", () => {
it("returns the matching device id", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: { devices: [{ id: "dev-1", name: "TS Bot", is_active: false }] },
}),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.findDeviceByName("TS Bot")).resolves.toBe("dev-1");
});
it("returns null when no device name matches", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: { devices: [{ id: "dev-1", name: "Other", is_active: false }] },
}),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.findDeviceByName("TS Bot")).resolves.toBeNull();
});
});
describe("SpotifyConnectApi mutating calls", () => {
it("transfer() PUTs /v1/me/player with device_ids + play=false default", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.transfer("dev-1");
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player",
{ device_ids: ["dev-1"], play: false },
AUTH,
);
});
it("transfer(id, true) forwards play=true", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.transfer("dev-1", true);
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player",
{ device_ids: ["dev-1"], play: true },
AUTH,
);
});
it("play() PUTs /v1/me/player/play?device_id= with the uris body", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.play("dev-1", "spotify:track:abc");
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player/play",
{ uris: ["spotify:track:abc"] },
{ headers: { Authorization: "Bearer tok123" }, params: { device_id: "dev-1" } },
);
});
it("pause() PUTs /v1/me/player/pause (no params) with no body", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.pause();
expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, {
headers: { Authorization: "Bearer tok123" },
params: undefined,
});
});
it("pause(id) forwards device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.pause("dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { device_id: "dev-1" },
});
});
it("resume() PUTs /v1/me/player/play with no uris body (resume)", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.resume();
expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, {
headers: { Authorization: "Bearer tok123" },
params: undefined,
});
});
it("resume(id) forwards device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.resume("dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { device_id: "dev-1" },
});
});
it("seek() PUTs /v1/me/player/seek?position_ms=", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.seek(42000);
expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { position_ms: 42000 },
});
});
it("seek(ms, id) adds device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.seek(1000, "dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { position_ms: 1000, device_id: "dev-1" },
});
});
it("mutating calls no-op (no http.put) when unauthorized", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await api.transfer("dev-1");
await api.play("dev-1", "spotify:track:x");
await api.pause();
expect(http.put).not.toHaveBeenCalled();
});
});
/**
* REQUIRED CORRECTION C3.6: mutating calls must NOT reject up the queue-advance
* path. A transient 403 (non-Premium) / 404 (no active device) / 429
* (rate-limited) from Spotify must be swallowed (resolve to void), never thrown,
* so a failed play() degrades to "couldn't play" instead of an unhandled
* rejection that crashes the backend.
*/
describe("SpotifyConnectApi C3.6 — mutating calls are resilient (no throw)", () => {
// S4.6: transient statuses (404/429) now retry with backoff; inject a no-op
// sleep so these swallow-guarantee tests stay instant (no real timers). The
// no-throw/swallow contract asserted here is unchanged.
const noSleep = async () => {};
function rejectingHttp(status: number) {
const err: any = new Error(`http ${status}`);
err.response = { status };
return makeHttp({ put: vi.fn().mockRejectedValue(err) });
}
it("play() does NOT throw on a 404 (no active device)", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(404),
sleep: noSleep,
});
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
it("play() does NOT throw on a 429 (rate-limited)", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(429),
sleep: noSleep,
});
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
it("transfer() does NOT throw on a 403 (non-Premium)", async () => {
const api = new SpotifyConnectApi(token(), { http: rejectingHttp(403) });
await expect(api.transfer("dev-1", true)).resolves.toBeUndefined();
});
it("pause/resume/seek do NOT throw on a rejection", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(404),
sleep: noSleep,
});
await expect(api.pause("dev-1")).resolves.toBeUndefined();
await expect(api.resume("dev-1")).resolves.toBeUndefined();
await expect(api.seek(1000, "dev-1")).resolves.toBeUndefined();
});
it("play() does NOT throw on a raw network error (no response)", async () => {
const http = makeHttp({ put: vi.fn().mockRejectedValue(new Error("ECONNRESET")) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
});
/**
* Task S4.6: bounded retry/backoff on the mutating Connect commands
* (spec §4.3/§13 recovery/watchdog). Transient statuses {404,429,500,502,503}
* retry up to MAX_ATTEMPTS (3) with exponential backoff; non-transient statuses
* are NOT retried. Every path still preserves C3.6 (swallow, never throw). A
* no-op injected `sleep` keeps the tests instant (no real timers).
*/
describe("SpotifyConnectApi S4.6 — retry/backoff on mutating commands", () => {
const MAX_ATTEMPTS = 3;
const noSleep = async () => {};
function rejectStatus(status: number, headers?: Record<string, string>) {
const err: any = new Error(`http ${status}`);
err.response = { status, headers };
return err;
}
it("play() retries a transient 404 then succeeds (2 calls, no throw)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(404))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
});
it("play() exhausts on a persistent 500 (MAX_ATTEMPTS calls, swallowed, warns once)", async () => {
const put = vi.fn().mockRejectedValue(rejectStatus(500));
const http = makeHttp({ put });
const warn = vi.fn();
const logger = { warn } as any;
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep, logger });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(MAX_ATTEMPTS);
expect(warn).toHaveBeenCalledTimes(1);
});
it("does NOT retry a non-transient 403 (exactly ONE call, no throw)", async () => {
const put = vi.fn().mockRejectedValue(rejectStatus(403));
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(1);
});
it("429 honors a CAPPED Retry-After then succeeds (2 calls, bounded sleep)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(429, { "retry-after": "1" }))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const sleep = vi.fn<(ms: number) => Promise<void>>(async () => {});
const api = new SpotifyConnectApi(token(), { http, sleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
const delay = sleep.mock.calls[0][0];
expect(delay).toBe(1000);
expect(delay).toBeLessThanOrEqual(2000);
});
it("transfer() shares the retry path — 404 then success (2 calls)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(404))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.transfer("dev-1", true)).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
});
});
describe("SpotifyConnectApi.getPlaybackState", () => {
it("GETs /v1/me/player and maps is_playing/progress/item", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
is_playing: true,
progress_ms: 12345,
item: { uri: "spotify:track:abc", duration_ms: 200000 },
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const state = await api.getPlaybackState();
expect(http.get).toHaveBeenCalledWith("/v1/me/player", AUTH);
expect(state).toEqual({
isPlaying: true,
progressMs: 12345,
trackUri: "spotify:track:abc",
durationMs: 200000,
});
});
// R4-4 (multi-bot): the account-wide /v1/me/player response names the single
// ACTIVE Connect device. Expose it as activeDeviceId so the Rust backend can
// tell "our device" from a foreign device another bot stole the session with.
it("maps device.id -> activeDeviceId", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
is_playing: true,
progress_ms: 1000,
item: { uri: "spotify:track:abc", duration_ms: 200000 },
device: { id: "dev-1", name: "TS Bot", is_active: true },
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const state = await api.getPlaybackState();
expect(state).toEqual({
isPlaying: true,
progressMs: 1000,
trackUri: "spotify:track:abc",
durationMs: 200000,
activeDeviceId: "dev-1",
});
});
it("returns null on 204 (no active device)", async () => {
const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 204, data: "" }) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
});
it("returns null when item is missing / trackUri null", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({ status: 200, data: { is_playing: false, progress_ms: 0, item: null } }),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toEqual({
isPlaying: false,
progressMs: 0,
trackUri: null,
durationMs: 0,
});
});
it("returns null on rejection (e.g. 401) instead of throwing", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("boom")) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
});
it("returns null when getToken() is null (unauthorized) without calling http", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
expect(http.get).not.toHaveBeenCalled();
});
});
+205
View File
@@ -0,0 +1,205 @@
import axios, { type AxiosInstance } from "axios";
const API_BASE = "https://api.spotify.com";
/**
* Task S4.6 (spec §4.3/§13 recovery/watchdog): transient Connect-command
* failures that warrant a bounded retry with exponential backoff. 404 is the
* device-visibility latency case (device not yet enumerated), 429 is rate-limit,
* 5xx are Spotify-side flakiness. Everything else (401/403/network) is NOT
* retried — it is swallowed immediately (C3.6).
*/
const TRANSIENT = new Set([404, 429, 500, 502, 503]);
const MAX_ATTEMPTS = 3;
const BASE_DELAY_MS = 150;
const MAX_DELAY_MS = 2_000;
export interface SpotifyDevice {
id: string;
name: string;
is_active: boolean;
}
export interface PlaybackState {
isPlaying: boolean;
progressMs: number;
trackUri: string | null;
durationMs: number;
/**
* R4-4 (multi-bot): the id of the single ACTIVE Connect device the
* account-wide GET /v1/me/player is reporting (from `device.id`). Absent when
* Spotify omits the device block. The Rust backend uses it to tell "our
* device" from a foreign device another bot stole the shared session with, so
* it never misattributes that bot's track/stop as ours.
*/
activeDeviceId?: string;
}
/**
* Spotify Web API "Connect" remote-control client. Wraps an axios instance and
* attaches a live user Bearer token from getToken() to every request.
*
* Error policy:
* - Read-only calls (getDevices/getPlaybackState) degrade to []/null on error.
* - Mutating calls (transfer/play/pause/resume/seek) no-op when unauthorized.
* - REQUIRED CORRECTION C3.6: mutating calls ALSO swallow transport errors
* (403 non-Premium / 404 no active device / 429 rate-limited / network) and
* resolve to void instead of rejecting. The contract keeps the Promise<void>
* signatures, so the backend treats a failed play as "couldn't play" and
* falls back — a transient Spotify error can never surface as an unhandled
* rejection that crashes the queue-advance path.
*/
export class SpotifyConnectApi {
private getToken: () => Promise<string | null>;
private http: AxiosInstance;
private sleep: (ms: number) => Promise<void>;
private logger?: import("pino").Logger;
constructor(
getToken: () => Promise<string | null>,
deps?: {
http?: AxiosInstance;
sleep?: (ms: number) => Promise<void>;
logger?: import("pino").Logger;
},
) {
this.getToken = getToken;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.sleep = deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
this.logger = deps?.logger;
}
/** Bearer auth headers, or null when no valid user token is available. */
private async authHeaders(): Promise<{ Authorization: string } | null> {
const token = await this.getToken();
if (!token) return null;
return { Authorization: `Bearer ${token}` };
}
/**
* S4.6 recovery/watchdog: run a mutating PUT with bounded retry + exponential
* backoff on TRANSIENT statuses only. On exhaustion OR a non-transient error
* it SWALLOWS and warns once — preserving C3.6 (a mutating command NEVER
* rejects up the queue-advance path). Tokens are never logged.
*/
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);
}
}
}
async getDevices(): Promise<SpotifyDevice[]> {
const headers = await this.authHeaders();
if (!headers) return [];
try {
const { data } = await this.http.get("/v1/me/player/devices", { headers });
const list = Array.isArray(data?.devices) ? data.devices : [];
return list.map((d: any) => ({
id: d?.id ?? "",
name: d?.name ?? "",
is_active: Boolean(d?.is_active),
}));
} catch {
return [];
}
}
async findDeviceByName(name: string): Promise<string | null> {
const devices = await this.getDevices();
const match = devices.find((d) => d.name === name);
return match ? match.id : null;
}
async transfer(deviceId: string, play = false): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player", { device_ids: [deviceId], play }, { headers }),
);
}
async play(deviceId: string, trackUri: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put(
"/v1/me/player/play",
{ uris: [trackUri] },
{ headers, params: { device_id: deviceId } },
),
);
}
async pause(deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/pause", undefined, {
headers,
params: deviceId ? { device_id: deviceId } : undefined,
}),
);
}
async resume(deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/play", undefined, {
headers,
params: deviceId ? { device_id: deviceId } : undefined,
}),
);
}
async seek(ms: number, deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
const params: Record<string, unknown> = { position_ms: ms };
if (deviceId) params.device_id = deviceId;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/seek", undefined, { headers, params }),
);
}
async getPlaybackState(): Promise<PlaybackState | null> {
const headers = await this.authHeaders();
if (!headers) return null;
try {
const res = await this.http.get("/v1/me/player", { headers });
// 204 = no active device / playback; body is empty.
if (res.status === 204 || !res.data) return null;
const d = res.data;
return {
isPlaying: Boolean(d.is_playing),
progressMs: Number(d.progress_ms ?? 0),
trackUri: d.item?.uri ?? null,
durationMs: Number(d.item?.duration_ms ?? 0),
// R4-4: expose the account's single active device so a foreign steal is
// distinguishable from our own device. Left undefined when absent.
activeDeviceId: d.device?.id ?? undefined,
};
} catch {
return null;
}
}
}
+709
View File
@@ -0,0 +1,709 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import type { SpotifyOAuth } from "./spotify-oauth.js";
// Controllable, hoisted so the vi.mock factory can close over it.
// go-* keys keep their Stage-2 names (`supported`/`path`) so existing tests are
// untouched; rust* keys drive the new librespot selection paths.
const bin = vi.hoisted(() => ({
supported: true,
path: "",
rustSupported: true,
rustPath: "",
}));
vi.mock("./binary.js", async () => {
// existsSync mirrors the real resolveExecutable(find*()) result for the
// bin/-style temp paths this suite uses (existingBin exists, missingBin does
// not), keeping the chooseBackend/isAvailable matrix behavior-identical.
const { existsSync } = await import("node:fs");
return {
isGoLibrespotSupported: () => bin.supported,
findGoLibrespot: () => bin.path,
resetGoLibrespotBinaryCache: () => {},
checkGoLibrespotAvailable: async () => bin.supported && !!bin.path,
isRustLibrespotSupported: () => bin.rustSupported,
findLibrespot: () => bin.rustPath,
resetLibrespotBinaryCache: () => {},
checkLibrespotAvailable: async () => bin.rustSupported && !!bin.rustPath,
// PATH-aware presence gates (Bug I3): supported gate AND the resolved
// binary actually exists on disk.
isGoLibrespotPresent: () => bin.supported && existsSync(bin.path),
isLibrespotPresent: () => bin.rustSupported && existsSync(bin.rustPath),
};
});
// Capture options the DEFAULT factory hands to the real GoLibrespotBackend so
// we can assert per-bot ports (Fix 3) are threaded through. Every other test
// injects its own backendFactory, so this mock is inert for them.
const goLibrespotCtor = vi.hoisted(() => vi.fn());
vi.mock("./go-librespot.js", async () => {
const { EventEmitter } = await import("node:events");
return {
GoLibrespotBackend: class extends EventEmitter {
constructor(opts: any) {
super();
goLibrespotCtor(opts);
}
async start(): Promise<void> {}
isReady(): boolean {
return true;
}
stop(): void {}
async playTrack(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async seek(): Promise<void> {}
getPcmStream(): any {
return null;
}
getPositionMs(): number {
return 0;
}
},
};
});
// Import AFTER vi.mock so the mocked binary module is used.
const { SpotifyController, perBotDeviceName } = await import("./controller.js");
const existingBin = join(tmpdir(), `tsmb-golibrespot-${process.pid}`);
const missingBin = join(tmpdir(), `tsmb-golibrespot-missing-${process.pid}`);
beforeAll(() => {
writeFileSync(existingBin, "#!/bin/sh\n");
});
afterAll(() => {
try {
rmSync(existingBin);
} catch {
/* ignore */
}
});
beforeEach(() => {
bin.supported = true;
bin.path = existingBin;
bin.rustSupported = true;
bin.rustPath = missingBin;
});
class FakeBackend extends EventEmitter implements SpotifyAudioBackend {
startCalls = 0;
stopCalls = 0;
playCalls: string[] = [];
pauseCalls = 0;
resumeCalls = 0;
seekCalls: number[] = [];
ready = false;
startShouldReject = false;
playShouldReject = false;
/** When set, start() awaits this before resolving — lets a test interleave
* stop()/error DURING a mid-flight start (Bug I1). */
startGate?: Promise<void>;
readonly pcm = Readable.from([Buffer.alloc(0)]);
async start(): Promise<void> {
this.startCalls++;
if (this.startGate) await this.startGate;
if (this.startShouldReject) throw new Error("start boom");
this.ready = true;
}
stop(): void {
this.stopCalls++;
this.ready = false;
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
this.playCalls.push(uri);
if (this.playShouldReject) throw new Error("play boom");
}
async pause(): Promise<void> {
this.pauseCalls++;
}
async resume(): Promise<void> {
this.resumeCalls++;
}
async seek(ms: number): Promise<void> {
this.seekCalls.push(ms);
}
getPcmStream(): Readable {
return this.pcm;
}
getPositionMs(): number {
return 0;
}
}
const silentLogger = {
info() {},
error() {},
warn() {},
debug() {},
trace() {},
fatal() {},
child() {
return silentLogger;
},
} as unknown as Logger;
function cfg(over: Partial<SpotifyConfig> = {}): SpotifyConfig {
return {
enabled: true,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
...over,
};
}
function makeCtrl(over: {
config?: Partial<SpotifyConfig>;
backendFactory?: () => SpotifyAudioBackend;
oauth?: import("./spotify-oauth.js").SpotifyOAuth;
} = {}) {
const be = new FakeBackend();
const ctrl = new SpotifyController({
config: cfg(over.config),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
backendFactory: over.backendFactory ?? (() => be),
oauth: over.oauth,
});
return { ctrl, be };
}
function fakeOAuth(
authorized: boolean,
hooks: { onIsAuthorized?: () => void } = {},
): SpotifyOAuth {
return {
isAuthorized: () => {
hooks.onIsAuthorized?.();
return authorized;
},
getAccessToken: async () => (authorized ? "tok" : null),
getClientId: () => "cid",
getRedirectUri: () => "http://127.0.0.1:5588/login",
buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize", state: "s" }),
handleCallback: async () => true,
} as unknown as SpotifyOAuth;
}
describe("SpotifyController.isAvailable", () => {
it("true when enabled + supported + binary present", () => {
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(true);
});
it("false when config disabled", () => {
const { ctrl } = makeCtrl({ config: { enabled: false } });
expect(ctrl.isAvailable()).toBe(false);
});
it("false when platform unsupported", () => {
bin.supported = false;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
it("false when binary file is absent", () => {
bin.path = missingBin;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
});
describe("SpotifyController.ensureStarted", () => {
it("starts the backend exactly once across repeated calls", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("is idempotent under concurrent calls (single start)", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
const [a, b] = await Promise.all([ctrl.ensureStarted(), ctrl.ensureStarted()]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("returns false and does not build a backend when unavailable", async () => {
let built = 0;
const { ctrl } = makeCtrl({
config: { enabled: false },
backendFactory: () => {
built++;
return new FakeBackend();
},
});
expect(await ctrl.ensureStarted()).toBe(false);
expect(built).toBe(0);
});
it("returns false when backend.start() throws, and allows a later retry", async () => {
const be = new FakeBackend();
be.startShouldReject = true;
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(false);
// start failure clears the cached promise so a subsequent call retries.
be.startShouldReject = false;
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be.startCalls).toBe(2);
});
});
describe("SpotifyController.playTrack", () => {
it("ensures started then delegates the uri, returning true", async () => {
const { ctrl, be } = makeCtrl();
expect(await ctrl.playTrack("spotify:track:abc")).toBe(true);
expect(be.startCalls).toBe(1);
expect(be.playCalls).toEqual(["spotify:track:abc"]);
});
it("returns false when the controller is unavailable", async () => {
const { ctrl, be } = makeCtrl({ config: { enabled: false } });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
expect(be.playCalls).toEqual([]);
});
it("returns false when backend.playTrack rejects", async () => {
const be = new FakeBackend();
be.playShouldReject = true;
const { ctrl } = makeCtrl({ backendFactory: () => be });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
});
});
describe("SpotifyController transport delegation", () => {
it("pause/resume/seek forward to the backend after start", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.pause();
await ctrl.resume();
await ctrl.seek(4200);
expect(be.pauseCalls).toBe(1);
expect(be.resumeCalls).toBe(1);
expect(be.seekCalls).toEqual([4200]);
});
it("pause/resume/seek are safe no-ops before start", async () => {
const { ctrl, be } = makeCtrl();
await expect(ctrl.pause()).resolves.toBeUndefined();
await expect(ctrl.resume()).resolves.toBeUndefined();
await expect(ctrl.seek(10)).resolves.toBeUndefined();
expect(be.pauseCalls).toBe(0);
});
it("getPcmStream returns the backend stream", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
expect(ctrl.getPcmStream()).toBe(be.pcm);
});
it("getPcmStream throws before the backend is started", () => {
const { ctrl } = makeCtrl();
expect(() => ctrl.getPcmStream()).toThrow();
});
});
// R4-1: the web progress bar computes seekTime = ratio * duration (fractional
// seconds); BotInstance routes Spotify seeks through seek(seconds * 1000),
// yielding a NON-integer ms (e.g. 71610.00000000001). go-librespot decodes
// `position` into an int64 and Go's encoding/json REJECTS a JSON number with a
// decimal point -> HTTP 400 -> the error is swallowed -> the track never seeks.
// The controller must round ONCE so BOTH backends get a valid integer ms.
describe("SpotifyController.seek integer rounding (R4-1)", () => {
it("rounds a fractional seek to an integer ms before forwarding to the backend", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(71610.00000000001);
expect(be.seekCalls).toHaveLength(1);
expect(Number.isInteger(be.seekCalls[0])).toBe(true);
expect(be.seekCalls[0]).toBe(71610);
});
it("clamps a negative seek to 0", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(-5);
expect(be.seekCalls).toEqual([0]);
});
it("passes an already-integer seek through unchanged", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(4200);
expect(be.seekCalls).toEqual([4200]);
});
});
describe("SpotifyController event re-emission", () => {
it("re-emits backend trackEnded with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyTrackEndedEvent[] = [];
ctrl.on("trackEnded", (e) => got.push(e));
const evt: SpotifyTrackEndedEvent = { uri: "spotify:track:x", reason: "ended" };
be.emit("trackEnded", evt);
expect(got).toEqual([evt]);
});
it("re-emits backend metadata with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyNowPlaying[] = [];
ctrl.on("metadata", (m) => got.push(m));
const meta: SpotifyNowPlaying = {
uri: "spotify:track:x",
name: "Song",
artist: "Artist",
album: "Album",
coverUrl: "http://img",
durationMs: 1000,
};
be.emit("metadata", meta);
expect(got).toEqual([meta]);
});
});
describe("SpotifyController backend error handling (C3)", () => {
it("does NOT throw on a backend 'error' with no controller listener, and marks not-ready", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
expect(built).toBe(1);
// The controller itself has NO "error" listener. A raw re-emit would make
// Node throw here; the controller must swallow+log instead.
expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow();
// Marked not-ready: a fresh ensureStarted relaunches a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be2.startCalls).toBe(1);
});
it("tears down the errored backend (stop + detach + null) so it is not orphaned", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
expect(built).toBe(1);
// On "error" the controller must stop() the errored backend (cleans its
// ffmpeg/go-librespot children + FIFO) and detach ALL its listeners.
expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow();
expect(be1.stopCalls).toBe(1);
expect(be1.listenerCount("error")).toBe(0);
expect(be1.listenerCount("trackEnded")).toBe(0);
expect(be1.listenerCount("metadata")).toBe(0);
// Internal backend is null: a transport call no-ops (delegates to nothing)
// and getPcmStream throws until a rebuild.
await ctrl.pause();
expect(be1.pauseCalls).toBe(0);
expect(() => ctrl.getPcmStream()).toThrow();
// A fresh ensureStarted builds a NEW backend instance.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be2.startCalls).toBe(1);
expect(ctrl.getPcmStream()).toBe(be2.pcm);
});
it("does not cross-talk: a later error from the ORPHANED backend leaves the healthy rebuilt controller ready", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
// First error tears down be1 and the controller rebuilds onto healthy be2.
be1.emit("error", new Error("sidecar boom"));
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
await ctrl.pause();
expect(be2.pauseCalls).toBe(1);
// A SECOND error emitted by the OLD (orphaned) backend must NOT reach the
// controller: the healthy be2 stays owned, ready, and untouched.
be1.on("error", () => {}); // controller detached; re-arm to avoid unhandled throw
expect(() => be1.emit("error", new Error("orphan boom"))).not.toThrow();
expect(be2.stopCalls).toBe(0); // healthy backend not torn down
// Still ready: ensureStarted returns true without rebuilding a third time.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
await ctrl.pause();
expect(be2.pauseCalls).toBe(2);
expect(ctrl.getPcmStream()).toBe(be2.pcm);
});
});
describe("SpotifyController.stop", () => {
it("stops the backend and tears down state so a later start rebuilds", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
const { ctrl } = makeCtrl({ backendFactory: () => backends.shift()! });
await ctrl.ensureStarted();
ctrl.stop();
expect(be1.stopCalls).toBe(1);
// After teardown getPcmStream is invalid again until re-started.
expect(() => ctrl.getPcmStream()).toThrow();
// A fresh ensureStarted builds a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(be2.startCalls).toBe(1);
});
it("stop before start is a safe no-op", () => {
const { ctrl, be } = makeCtrl();
expect(() => ctrl.stop()).not.toThrow();
expect(be.stopCalls).toBe(0);
});
it("Bug I1: stop() DURING a mid-flight start tears the sidecar down instead of resurrecting it", async () => {
// A Deferred the test resolves to complete backend.start() on demand.
let resolveStart!: () => void;
const startGate = new Promise<void>((res) => {
resolveStart = res;
});
const be = new FakeBackend();
be.startGate = startGate;
const { ctrl } = makeCtrl({ backendFactory: () => be });
// Kick off ensureStarted but DO NOT await — start() is parked on the gate.
const startedP = ctrl.ensureStarted();
// Let the ensureStarted IIFE run up to `await backend.start()`.
await Promise.resolve();
await Promise.resolve();
expect(be.startCalls).toBe(1); // start() was entered and is now pending
// Caller tears the controller down while start() is still in flight
// (a user `!stop`/disconnect). Pre-fix this.backend is still null so this
// is a no-op and the spawned sidecar is orphaned.
ctrl.stop();
// Now let start() finally resolve. Pre-fix the IIFE would set this.backend
// and started=true, RESURRECTING the sidecar the caller already stopped.
resolveStart();
const result = await startedP;
// Post-fix: the mid-flight backend is torn down, not promoted.
expect(result).toBe(false);
expect(be.stopCalls).toBeGreaterThanOrEqual(1); // the fake WAS stopped
expect(be.listenerCount("trackEnded")).toBe(0); // listeners detached
expect(() => ctrl.getPcmStream()).toThrow(); // not resurrected
});
});
describe("SpotifyController per-bot ports (Fix 3)", () => {
it("threads apiPort + callbackPort into the default GoLibrespotBackend", async () => {
goLibrespotCtor.mockClear();
// No injected backendFactory → the controller builds the (mocked) real backend.
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
apiPort: 3712,
callbackPort: 8712,
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ apiPort: 3712, callbackPort: 8712 }),
);
});
});
describe("perBotDeviceName (corner-case R2-5)", () => {
it("suffixes the base name with the instanceId", () => {
expect(perBotDeviceName("TSMusicBot", "bot1")).toBe("TSMusicBot-bot1");
});
it("returns the base name unchanged when no instanceId is given", () => {
expect(perBotDeviceName("TSMusicBot", undefined)).toBe("TSMusicBot");
});
});
describe("SpotifyController per-bot device name (corner-case R2-5)", () => {
it("applies the per-bot suffix to the backend deviceName when instanceId is set", async () => {
goLibrespotCtor.mockClear();
// No injected backendFactory → the controller builds the (mocked) real backend.
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
instanceId: "bot1",
});
expect(await ctrl.ensureStarted()).toBe(true);
// The Connect identity is UNIQUE per bot ("<base>-<id>") so two bots under
// one account never register the same name (misroute / false-readiness).
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ deviceName: "TSMusicBot-bot1" }),
);
});
it("leaves the base deviceName unchanged when no instanceId (behavior-preserving)", async () => {
goLibrespotCtor.mockClear();
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ deviceName: "TSMusicBot" }),
);
});
});
describe("SpotifyController.chooseBackend (platform x config matrix)", () => {
function pick(
backend: SpotifyConfig["backend"],
opts: { go: boolean; goSupported?: boolean; rust: boolean; rustSupported?: boolean },
) {
bin.supported = opts.goSupported ?? true;
bin.path = opts.go ? existingBin : missingBin;
bin.rustSupported = opts.rustSupported ?? true;
bin.rustPath = opts.rust ? existingBin : missingBin;
const { ctrl } = makeCtrl({ config: { backend } });
return ctrl.chooseBackend();
}
it("auto: prefers go-librespot when linux + go binary present", () => {
expect(pick("auto", { go: true, rust: true })).toBe("go-librespot");
});
it("auto: falls back to librespot when go unsupported (e.g. Windows) but librespot present", () => {
expect(pick("auto", { go: true, goSupported: false, rust: true })).toBe("librespot");
});
it("auto: falls back to librespot when go binary is absent", () => {
expect(pick("auto", { go: false, rust: true })).toBe("librespot");
});
it("auto: null when neither backend is usable", () => {
expect(pick("auto", { go: false, goSupported: false, rust: false })).toBeNull();
});
it("go-librespot: selected when supported + present", () => {
expect(pick("go-librespot", { go: true, rust: true })).toBe("go-librespot");
});
it("go-librespot: null when unsupported, even if librespot is present", () => {
expect(pick("go-librespot", { go: true, goSupported: false, rust: true })).toBeNull();
});
it("librespot: selected when the librespot binary is present", () => {
expect(pick("librespot", { go: true, rust: true })).toBe("librespot");
});
it("librespot: null when the librespot binary is absent, even if go is present", () => {
expect(pick("librespot", { go: true, rust: false })).toBeNull();
});
});
describe("SpotifyController Rust-backend auth gate", () => {
it("isAvailable is true for a present librespot binary regardless of auth", () => {
bin.rustPath = existingBin;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(false),
});
expect(ctrl.isAvailable()).toBe(true);
});
it("ensureStarted returns false (no backend built) when Rust chosen but unauthorized", async () => {
bin.rustPath = existingBin;
let built = 0;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(false),
backendFactory: () => {
built++;
return new FakeBackend();
},
});
expect(await ctrl.ensureStarted()).toBe(false);
expect(built).toBe(0);
});
it("ensureStarted starts the Rust backend once authorized", async () => {
bin.rustPath = existingBin;
const be = new FakeBackend();
let built = 0;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(true),
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("go-librespot path never consults oauth.isAuthorized()", async () => {
// auto + go present -> go-librespot; the auth gate must be skipped so a
// throwing isAuthorized() is never reached.
const oauth = fakeOAuth(false, {
onIsAuthorized: () => {
throw new Error("isAuthorized must not be called on the go path");
},
});
const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth });
expect(await ctrl.ensureStarted()).toBe(true);
});
it("exposes the shared oauth + connect instances", () => {
const oauth = fakeOAuth(true);
const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth });
expect(ctrl.getOAuth()).toBe(oauth);
expect(ctrl.getConnect()).toBeDefined();
});
});
+405
View File
@@ -0,0 +1,405 @@
import { EventEmitter } from "node:events";
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
rmSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type { Readable } from "node:stream";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { isGoLibrespotPresent, isLibrespotPresent } from "./binary.js";
import { GoLibrespotBackend } from "./go-librespot.js";
import { RustLibrespotBackend } from "./rust-librespot.js";
import {
SpotifyOAuth,
type OAuthTokens,
type OAuthTokenStore,
} from "./spotify-oauth.js";
import { SpotifyConnectApi } from "./connect-api.js";
import {
resolveSpotifyBackendKind,
type SpotifyBackendKind,
} from "./backend-select.js";
export type { SpotifyBackendKind }; // keep the name exported for existing importers
/**
* Derive the per-bot Spotify Connect device name from the user-configured base.
*
* `config.spotify` is a single process-wide object shared by every BotInstance,
* so `config.spotify.deviceName` is identical for all bots. On the Rust
* (librespot) backend each bot would otherwise spawn `librespot --name <base>`
* with NO uniqueness, registering TWO Connect devices with the SAME name under
* the one shared Premium account — so `findDeviceByName` / `waitForDevice`
* (which match by name) could target the OTHER bot's device, misrouting
* transfer()+play() and reporting false readiness (corner-case R2-5).
*
* Suffixing the base with the bot's instanceId makes the Connect identity
* unique per bot. Pure + deterministic: same inputs → same name across
* restarts. Returns the base unchanged when no instanceId is supplied (keeps
* existing single-bot / non-injected behavior byte-for-byte).
*/
export function perBotDeviceName(base: string, instanceId?: string): string {
return instanceId ? `${base}-${instanceId}` : base;
}
/**
* Minimal file-backed OAuth token store used when the caller does not inject a
* SpotifyOAuth. Persists the rotating refresh-token JSON next to the bot config
* (0600). All IO is lazy + guarded so construction never throws and a
* missing/corrupt file simply reads as "unauthorized".
*/
class FileOAuthTokenStore implements OAuthTokenStore {
constructor(private readonly file: string) {}
load(): OAuthTokens | null {
try {
if (!existsSync(this.file)) return null;
return JSON.parse(readFileSync(this.file, "utf8")) as OAuthTokens;
} catch {
return null;
}
}
save(t: OAuthTokens): void {
mkdirSync(dirname(this.file), { recursive: true });
writeFileSync(this.file, JSON.stringify(t), { mode: 0o600 });
}
clear(): void {
try {
rmSync(this.file, { force: true });
} catch {
/* ignore */
}
}
}
export interface SpotifyControllerOptions {
config: SpotifyConfig;
workDir: string;
configDir: string;
logger: Logger;
/** Owning bot's id; suffixed onto the shared base deviceName so each bot's
* Spotify Connect identity is unique (avoids multi-bot device collision /
* misroute — corner-case R2-5). Omitted → the base name is used unchanged. */
instanceId?: string;
/** Per-bot go-librespot control-API port (distinct per bot to avoid binds). */
apiPort?: number;
/** Per-bot go-librespot OAuth callback port (distinct per bot). */
callbackPort?: number;
/** Injected for tests; when set it overrides the per-kind default builders. */
backendFactory?: () => SpotifyAudioBackend;
/** Injected for tests; defaults to a file-backed SpotifyOAuth in configDir. */
oauth?: SpotifyOAuth;
/** Injected for tests; defaults to a SpotifyConnectApi wired to oauth. */
connect?: SpotifyConnectApi;
}
/**
* Per-bot orchestrator for the Spotify sidecar. Selects a backend for this
* host+config (chooseBackend), owns backend lifecycle, gates on availability
* (config + platform + binary) plus — for the Rust librespot backend — OAuth
* authorization, delegates transport, and re-emits "trackEnded"/"metadata" so
* BotInstance can advance the queue exactly as for the ffmpeg path.
*
* Correction C3 (unchanged): this controller does NOT re-emit a raw "error"
* event. It subscribes to the backend's "error", logs it, tears the backend
* down, and marks itself not-ready so the next ensureStarted() relaunches a
* fresh backend. getPcmStream() proxies the backend's SINGLE persistent stream.
*/
export class SpotifyController extends EventEmitter {
private readonly config: SpotifyConfig;
private readonly workDir: string;
private readonly configDir: string;
private readonly logger: Logger;
private readonly instanceId?: string;
private readonly apiPort?: number;
private readonly callbackPort?: number;
private readonly injectedFactory?: () => SpotifyAudioBackend;
private readonly oauth: SpotifyOAuth;
private readonly connect: SpotifyConnectApi;
private backend: SpotifyAudioBackend | null = null;
// The in-flight backend during a start() that has not yet completed. A
// stop()/handleBackendError() DURING start() clears (or replaces) this so the
// mid-start sidecar is torn down and a completing start is discarded by the
// ensureStarted post-await guard instead of resurrecting a backend the caller
// already tore down (Bug I1).
private pendingBackend: SpotifyAudioBackend | null = null;
private started = false;
private startPromise: Promise<boolean> | null = null;
constructor(o: SpotifyControllerOptions) {
super();
this.config = o.config;
this.workDir = o.workDir;
this.configDir = o.configDir;
this.logger = o.logger;
this.instanceId = o.instanceId;
this.apiPort = o.apiPort;
this.callbackPort = o.callbackPort;
this.injectedFactory = o.backendFactory;
// The controller OWNS a shared OAuth + Connect pair (Task 6 web router and
// the Rust backend reuse these exact instances). Constructing the defaults
// performs no IO/network — the file store loads lazily on first use.
this.oauth =
o.oauth ??
new SpotifyOAuth({
store: new FileOAuthTokenStore(
join(this.configDir, "spotify-oauth.json"),
),
});
this.connect =
o.connect ??
new SpotifyConnectApi(() => this.oauth.getAccessToken(), {
logger: this.logger,
});
}
/** Shared OAuth client (web router + Rust backend reuse this instance). */
getOAuth(): SpotifyOAuth {
return this.oauth;
}
/** Shared Connect API client (Rust backend reuses this instance). */
getConnect(): SpotifyConnectApi {
return this.connect;
}
private goPresent(): boolean {
// PATH-aware, sync presence gate (Bug I3): a bare PATH-installed binary is
// resolved against $PATH, not existsSync()'d against process.cwd().
return isGoLibrespotPresent();
}
private rustPresent(): boolean {
return isLibrespotPresent();
}
/**
* Resolve which backend to run for this platform + config, or null when none
* is usable (caller falls back to the Stage-1 sentinel message):
* "go-librespot" -> GoLibrespot iff supported (linux) + binary present
* "librespot" -> Rust iff librespot(.exe) present (all platforms)
* "auto" -> GoLibrespot when (linux + go binary), else Rust when
* librespot present, else null.
*/
chooseBackend(): SpotifyBackendKind | null {
return resolveSpotifyBackendKind(
this.config.backend,
this.goPresent(),
this.rustPresent(),
);
}
/** enabled in config AND a backend is selectable (platform + binary present). */
isAvailable(): boolean {
return this.config.enabled && this.chooseBackend() !== null;
}
/** Build the concrete backend for the chosen kind (or the injected fake). */
private buildBackend(kind: SpotifyBackendKind): SpotifyAudioBackend {
if (this.injectedFactory) return this.injectedFactory();
// Compute the effective (per-bot-unique) Connect device name ONCE and pass
// the SAME value to whichever backend we build, so `librespot --name`,
// findDeviceByName(), and waitForDevice() all key on one identity — that
// consistency is what prevents the multi-bot misroute / false-readiness
// (corner-case R2-5). The user-configured base (config.deviceName) is left
// untouched; the suffix is applied only to the backend/Connect identity.
const deviceName = perBotDeviceName(this.config.deviceName, this.instanceId);
if (kind === "librespot") {
return new RustLibrespotBackend({
deviceName,
bitrate: this.config.bitrate,
cacheDir: join(this.workDir, "librespot-cache"),
oauth: this.oauth,
connect: this.connect,
logger: this.logger,
});
}
return new GoLibrespotBackend({
deviceName,
bitrate: this.config.bitrate,
workDir: this.workDir,
configDir: this.configDir,
apiPort: this.apiPort,
callbackPort: this.callbackPort,
logger: this.logger,
});
}
/**
* Idempotently start the selected backend. Returns false (without building a
* backend) when unavailable, or — for the Rust backend — when OAuth is not
* yet authorized, so callers show the login-needed / fallback message. A
* failed start clears the cached promise so a later call can retry.
*/
async ensureStarted(): Promise<boolean> {
if (!this.isAvailable()) return false;
const kind = this.chooseBackend();
if (!kind) return false;
// The Rust librespot device only appears in Spotify Connect once the user
// has authorized OAuth; without it, do not spawn a dead sidecar.
if (kind === "librespot" && !this.oauth.isAuthorized()) return false;
if (this.started) {
if (this.backend?.isReady()) return true;
this.stop();
}
if (this.startPromise) return this.startPromise;
this.startPromise = (async () => {
const backend = this.buildBackend(kind);
// Publish the in-flight backend BEFORE the (potentially ~20s) start()
// await so a concurrent stop()/handleBackendError() can reach and tear
// down this mid-start sidecar (Bug I1).
this.pendingBackend = backend;
backend.on("trackEnded", (e: SpotifyTrackEndedEvent) =>
this.emit("trackEnded", e),
);
backend.on("metadata", (m: SpotifyNowPlaying) =>
this.emit("metadata", m),
);
// C3: do NOT re-emit "error". Log and mark not-ready so the next
// ensureStarted() relaunches a fresh backend.
backend.on("error", (err?: unknown) => this.handleBackendError(err));
try {
await backend.start();
} catch (err) {
this.logger.error({ err }, "Spotify backend failed to start");
if (this.pendingBackend === backend) this.pendingBackend = null;
this.startPromise = null;
return false;
}
// Post-await guard (Bug I1): if teardown ran DURING start() — stop() or
// handleBackendError() cleared/replaced pendingBackend — do NOT promote
// this backend. Tear the just-started sidecar down so it is neither
// leaked nor resurrected, and report failure to the caller.
if (this.pendingBackend !== backend) {
this.teardownBackend(
backend,
"Spotify backend stop() threw tearing down a superseded start",
);
return false;
}
this.pendingBackend = null;
this.backend = backend;
this.started = true;
return true;
})();
return this.startPromise;
}
/**
* Stop a backend and detach ALL its listeners, swallowing+logging any throw
* from stop() so teardown never propagates. Shared by the error/stop paths
* and the ensureStarted post-await guard.
*/
private teardownBackend(be: SpotifyAudioBackend, stopMsg: string): void {
try {
be.stop();
} catch (stopErr) {
this.logger.error({ err: stopErr }, stopMsg);
}
(be as unknown as EventEmitter).removeAllListeners();
}
/**
* Tear down an in-flight (mid-start) backend so a start() still awaiting is
* discarded by ensureStarted's post-await guard rather than promoted, and its
* spawned sidecar is killed rather than orphaned (Bug I1).
*/
private teardownPendingBackend(): void {
if (!this.pendingBackend) return;
this.teardownBackend(
this.pendingBackend,
"Spotify backend stop() threw tearing down in-flight start",
);
this.pendingBackend = null;
}
/**
* C3 backend-error handler. Never re-emits "error" (an unhandled "error" on
* an EventEmitter throws). Logs, tears the errored backend down, and marks
* the controller not-ready so the next ensureStarted() relaunches it.
*/
private handleBackendError(err: unknown): void {
this.logger.error({ err }, "Spotify backend error; marking not-ready");
if (this.backend) {
this.teardownBackend(
this.backend,
"Spotify backend stop() threw during error teardown",
);
}
this.backend = null;
// Also kill an in-flight start so its post-await guard discards it.
this.teardownPendingBackend();
this.started = false;
this.startPromise = null;
}
/** Ensure started, then play the spotify: URI. False on any failure. */
async playTrack(uri: string): Promise<boolean> {
const ok = await this.ensureStarted();
if (!ok || !this.backend) return false;
try {
await this.backend.playTrack(uri);
return true;
} catch (err) {
this.logger.error({ err, uri }, "Spotify playTrack failed");
return false;
}
}
async pause(): Promise<void> {
if (this.backend) await this.backend.pause();
}
async resume(): Promise<void> {
if (this.backend) await this.backend.resume();
}
async seek(ms: number): Promise<void> {
// R4-1: round to an integer ms ONCE here so BOTH backends receive a valid
// integer position. The web progress bar computes seekTime = ratio *
// duration (fractional seconds), so seek(seconds * 1000) is a NON-integer ms
// (e.g. 71610.00000000001). go-librespot decodes `position` into an int64
// and Go's encoding/json rejects a JSON number with a decimal point → HTTP
// 400 → the error is swallowed → the track never seeks. The Spotify Web API
// (Rust path) likewise expects an integer position_ms. Clamp negatives to 0.
const position = Math.max(0, Math.round(ms));
if (this.backend) await this.backend.seek(position);
}
getPcmStream(): Readable {
if (!this.backend) {
throw new Error("Spotify backend not started");
}
return this.backend.getPcmStream();
}
/**
* Tear down the backend and reset lifecycle state (safe before start).
* Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds
* a fresh backend.
*/
stop(): void {
if (this.backend) {
this.teardownBackend(
this.backend,
"Spotify backend stop() threw during teardown",
);
}
this.backend = null;
// Also kill an in-flight start so its post-await guard discards it rather
// than resurrecting the sidecar this stop() just tore down (Bug I1).
this.teardownPendingBackend();
this.started = false;
this.startPromise = null;
}
}
+299
View File
@@ -0,0 +1,299 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { AxiosInstance } from "axios";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
/** Minimal axios stub: only get/post are exercised by the client. */
function makeHttp(overrides?: Partial<Record<"get" | "post", any>>) {
return {
get: vi.fn().mockResolvedValue({ status: 200, data: {} }),
post: vi.fn().mockResolvedValue({ status: 200, data: {} }),
...overrides,
} as unknown as AxiosInstance;
}
describe("GoLibrespotRestClient", () => {
it("ping() returns true on GET / -> 200", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(true);
expect(http.get).toHaveBeenCalledWith("/");
});
it("ping() returns false when GET / rejects (daemon not up)", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(false);
});
it("playTrack() POSTs /player/play with the uri body", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.playTrack("spotify:track:abc123");
expect(http.post).toHaveBeenCalledWith("/player/play", { uri: "spotify:track:abc123" });
});
it("pause/resume/stop POST their bodyless endpoints", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.pause();
await client.resume();
await client.stop();
expect(http.post).toHaveBeenNthCalledWith(1, "/player/pause");
expect(http.post).toHaveBeenNthCalledWith(2, "/player/resume");
expect(http.post).toHaveBeenNthCalledWith(3, "/player/stop");
});
it("seek() POSTs /player/seek with position(ms) and relative:false", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.seek(42000);
expect(http.post).toHaveBeenCalledWith("/player/seek", { position: 42000, relative: false });
});
it("playTrack() rejects when the POST fails (surfaced to caller)", async () => {
const http = makeHttp({ post: vi.fn().mockRejectedValue(new Error("boom")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.playTrack("spotify:track:x")).rejects.toThrow("boom");
});
it("getStatus() normalizes the /status shape (ms position/duration)", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
},
}),
});
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(http.get).toHaveBeenCalledWith("/status");
expect(status).toEqual({
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
});
});
it("getStatus() returns null with a null track when nothing is loaded", async () => {
const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 200, data: { stopped: true, paused: false, buffering: false, track: null } }) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(status).toEqual({ stopped: true, paused: false, buffering: false, track: null });
});
it("getStatus() returns null when GET /status rejects", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("down")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.getStatus()).resolves.toBeNull();
});
});
/** Fake ws: records instances, lets tests drive open/message/close/error. */
class FakeWebSocket extends EventEmitter {
static instances: FakeWebSocket[] = [];
closed = false;
constructor(public url: string) {
super();
FakeWebSocket.instances.push(this);
}
close() {
this.closed = true;
this.emit("close");
}
}
function frame(type: string, data: unknown): Buffer {
return Buffer.from(JSON.stringify({ type, data }));
}
describe("GoLibrespotEventClient", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
});
it("emits 'not_playing' (track-end) with its data payload", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onEnded = vi.fn();
client.on("not_playing", onEnded);
client.start();
const ws = FakeWebSocket.instances[0];
expect(ws.url).toBe("ws://127.0.0.1:3678/events");
ws.emit("message", frame("not_playing", { uri: "spotify:track:abc", play_origin: "go-librespot" }));
expect(onEnded).toHaveBeenCalledTimes(1);
expect(onEnded).toHaveBeenCalledWith({ uri: "spotify:track:abc", play_origin: "go-librespot" });
client.stop();
});
it("emits 'metadata' with the now-playing object", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onMeta = vi.fn();
client.on("metadata", onMeta);
client.start();
FakeWebSocket.instances[0].emit(
"message",
frame("metadata", { uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }),
);
expect(onMeta).toHaveBeenCalledWith({ uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 });
client.stop();
});
it("ignores non-JSON frames without throwing", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
const onAny = vi.fn();
client.on("metadata", onAny);
client.start();
expect(() => FakeWebSocket.instances[0].emit("message", Buffer.from("not json"))).not.toThrow();
expect(onAny).not.toHaveBeenCalled();
client.stop();
});
it("reconnects with backoff after the socket closes", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(FakeWebSocket.instances).toHaveLength(1);
FakeWebSocket.instances[0].emit("close");
expect(FakeWebSocket.instances).toHaveLength(1); // not immediate
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // reconnected
client.stop();
} finally {
vi.useRealTimers();
}
});
it("stop() closes the socket and prevents reconnect", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
const ws = FakeWebSocket.instances[0];
client.stop();
expect(ws.closed).toBe(true);
vi.advanceTimersByTime(60000);
expect(FakeWebSocket.instances).toHaveLength(1); // no new socket
} finally {
vi.useRealTimers();
}
});
it("does not throw on socket 'error' when no error listener is attached", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(() => FakeWebSocket.instances[0].emit("error", new Error("net"))).not.toThrow();
client.stop();
});
// R4-3: a WS drop at a track boundary can lose the not_playing/stopped event.
// After a successful RE-open the client emits "reconnected" so the backend can
// re-query GET /status and reconcile. The FIRST connect must NOT emit it (there
// is nothing to reconcile yet).
it("emits 'reconnected' after a reconnect open, but NOT on the initial connect", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
const onReconnected = vi.fn();
client.on("reconnected", onReconnected);
client.start();
FakeWebSocket.instances[0].emit("open"); // initial connect
expect(onReconnected).not.toHaveBeenCalled(); // no re-sync on first connect
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // reconnected socket
FakeWebSocket.instances[1].emit("open"); // reconnect open
expect(onReconnected).toHaveBeenCalledTimes(1);
client.stop();
} finally {
vi.useRealTimers();
}
});
// R4-5: a socket that is accepted then immediately closed (a flap) must let the
// exponential backoff GROW — the old code reset it to 500ms on every 'open',
// pinning reconnects at ~2 Hz.
it("grows the reconnect backoff across open→immediate-close flaps (no 500ms pin)", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
// Flap #1: accept then immediately drop -> next reconnect at 500ms.
FakeWebSocket.instances[0].emit("open");
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2);
// Flap #2: accept then immediately drop -> backoff has doubled to 1000ms.
FakeWebSocket.instances[1].emit("open");
FakeWebSocket.instances[1].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // still 2: 500ms is NOT enough now
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(3); // reconnects only after 1000ms
client.stop();
} finally {
vi.useRealTimers();
}
});
// R4-5: once a connection has been STABLE (up for >= STABLE_CONNECTION_MS) the
// backoff resets, so a later drop reconnects promptly again.
it("resets the reconnect backoff after a stable connection", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
// Flap once to grow the backoff to 1000ms.
FakeWebSocket.instances[0].emit("open");
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2);
// Now a STABLE connection: open and stay up past the stability threshold.
FakeWebSocket.instances[1].emit("open");
vi.advanceTimersByTime(5000); // >= STABLE_CONNECTION_MS -> backoff reset to 500
FakeWebSocket.instances[1].emit("close");
vi.advanceTimersByTime(499);
expect(FakeWebSocket.instances).toHaveLength(2); // not yet
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3); // reconnected at 500ms -> backoff was reset
client.stop();
} finally {
vi.useRealTimers();
}
});
});
+235
View File
@@ -0,0 +1,235 @@
import { EventEmitter } from "node:events";
import axios, { type AxiosInstance } from "axios";
import WebSocket from "ws";
export interface GoLibrespotStatusTrack {
uri: string;
name: string;
artist_names: string[];
album_name: string;
album_cover_url: string | null;
position: number;
duration: number;
}
export interface GoLibrespotStatus {
stopped: boolean;
paused: boolean;
buffering: boolean;
track: GoLibrespotStatusTrack | null;
}
export class GoLibrespotRestClient {
private http: AxiosInstance;
constructor(baseUrl: string, deps?: { http?: AxiosInstance }) {
this.http =
deps?.http ??
axios.create({
baseURL: baseUrl,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
}
async ping(): Promise<boolean> {
try {
const res = await this.http.get("/");
return res.status === 200;
} catch {
return false;
}
}
async playTrack(uri: string): Promise<void> {
await this.http.post("/player/play", { uri });
}
async pause(): Promise<void> {
await this.http.post("/player/pause");
}
async resume(): Promise<void> {
await this.http.post("/player/resume");
}
async stop(): Promise<void> {
await this.http.post("/player/stop");
}
async seek(ms: number): Promise<void> {
await this.http.post("/player/seek", { position: ms, relative: false });
}
async getStatus(): Promise<GoLibrespotStatus | null> {
try {
const res = await this.http.get("/status");
const d = res.data ?? {};
const t = d.track;
return {
stopped: Boolean(d.stopped),
paused: Boolean(d.paused),
buffering: Boolean(d.buffering),
track: t
? {
uri: t.uri ?? "",
name: t.name ?? "",
artist_names: Array.isArray(t.artist_names) ? t.artist_names : [],
album_name: t.album_name ?? "",
album_cover_url: t.album_cover_url ?? null,
position: t.position ?? 0,
duration: t.duration ?? 0,
}
: null,
};
} catch {
return null;
}
}
}
export type GoLibrespotEventType =
| "metadata"
| "playing"
| "paused"
| "not_playing"
| "stopped"
| "will_play"
| "seek"
| "active"
| "inactive"
| "volume"
| "playback_ready";
interface WsLike {
on(event: string, cb: (...args: any[]) => void): void;
close(): void;
}
type WebSocketCtor = new (url: string) => WsLike;
const INITIAL_RECONNECT_MS = 500;
const MAX_RECONNECT_MS = 10000;
// R4-5: a connection must stay up at least this long before we treat it as
// "stable" and reset the reconnect backoff. A socket that is accepted and then
// immediately closed (a flap) never reaches this, so the exponential backoff
// keeps growing instead of pinning the reconnect interval at INITIAL_RECONNECT_MS.
const STABLE_CONNECTION_MS = 5000;
/**
* Emitted (in addition to the go-librespot event types) after the socket has
* SUCCESSFULLY re-opened following a drop — never on the very first connect.
* Consumers use it to re-query GET /status and reconcile any track-end that was
* emitted by go-librespot during the WS-down window (R4-3).
*/
export type GoLibrespotSyntheticEvent = "reconnected";
export class GoLibrespotEventClient extends EventEmitter {
private wsUrl: string;
private WebSocketCtor: WebSocketCtor;
private ws: WsLike | null = null;
private stopped = false;
private reconnectDelay = INITIAL_RECONNECT_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// R4-3: false until the FIRST successful open. A later open is therefore a
// reconnect and warrants a "reconnected" re-sync signal.
private hasConnected = false;
// R4-5: fires STABLE_CONNECTION_MS after an open; only then is the backoff reset.
private stableTimer: ReturnType<typeof setTimeout> | null = null;
constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) {
super();
this.wsUrl = wsUrl;
this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor);
}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
this.clearStableTimer();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private connect(): void {
if (this.stopped) return;
const ws = new this.WebSocketCtor(this.wsUrl);
this.ws = ws;
ws.on("open", () => this.onOpen());
ws.on("message", (buf: unknown) => this.handleMessage(buf));
ws.on("close", () => {
this.ws = null;
// R4-5: the connection is gone — cancel the pending stability reset so a
// short-lived (flapping) socket never resets the backoff.
this.clearStableTimer();
this.scheduleReconnect();
});
ws.on("error", (err: unknown) => {
if (this.listenerCount("error") > 0) this.emit("error", err);
});
}
private onOpen(): void {
// R4-3: only a RE-open (a socket that had connected before, then dropped)
// needs reconciliation; the initial connect has nothing to catch up on.
const isReconnect = this.hasConnected;
this.hasConnected = true;
// R4-5: do NOT reset the backoff here. Arm a timer that resets it only once
// the connection has stayed up for STABLE_CONNECTION_MS; a flap that closes
// before then leaves the exponential backoff to keep growing.
this.armStableTimer();
if (isReconnect) this.emit("reconnected");
}
private armStableTimer(): void {
this.clearStableTimer();
this.stableTimer = setTimeout(() => {
this.stableTimer = null;
this.reconnectDelay = INITIAL_RECONNECT_MS;
}, STABLE_CONNECTION_MS);
(this.stableTimer as { unref?: () => void }).unref?.();
}
private clearStableTimer(): void {
if (this.stableTimer) {
clearTimeout(this.stableTimer);
this.stableTimer = null;
}
}
private handleMessage(buf: unknown): void {
let parsed: unknown;
try {
const text = Buffer.isBuffer(buf)
? buf.toString("utf8")
: typeof buf === "string"
? buf
: String(buf);
parsed = JSON.parse(text);
} catch {
return;
}
if (parsed && typeof (parsed as any).type === "string") {
this.emit((parsed as any).type, (parsed as any).data ?? {});
}
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
}
@@ -0,0 +1,119 @@
// src/music/spotify/go-librespot-config.test.ts
import { describe, it, expect } from "vitest";
import { renderConfigYml, type GoLibrespotConfigOptions } from "./go-librespot-config.js";
const OPTS: GoLibrespotConfigOptions = {
deviceName: "TeamSpeak Music Bot",
bitrate: 320,
fifoPath: "/tmp/go-librespot.fifo",
apiAddress: "0.0.0.0",
apiPort: 3678,
callbackPort: 8080,
};
/**
* Minimal 2-level YAML reader for the exact shape renderConfigYml emits
* (flat scalars + one level of nesting under `server:` / `credentials:`).
* Avoids adding a yaml dependency while still proving the output round-trips.
*/
function parseTinyYaml(src: string): Record<string, unknown> {
const root: Record<string, unknown> = {};
const stack: Array<{ indent: number; obj: Record<string, unknown> }> = [
{ indent: -1, obj: root },
];
for (const rawLine of src.split("\n")) {
if (rawLine.trim() === "") continue;
const indent = rawLine.length - rawLine.trimStart().length;
const line = rawLine.trim();
const idx = line.indexOf(":");
const key = line.slice(0, idx).trim();
let valRaw = line.slice(idx + 1).trim();
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const parent = stack[stack.length - 1].obj;
if (valRaw === "") {
const child: Record<string, unknown> = {};
parent[key] = child;
stack.push({ indent, obj: child });
continue;
}
let val: unknown = valRaw;
if (valRaw.startsWith('"') && valRaw.endsWith('"')) val = JSON.parse(valRaw);
else if (valRaw === "true") val = true;
else if (valRaw === "false") val = false;
else if (/^-?\d+$/.test(valRaw)) val = Number(valRaw);
parent[key] = val;
}
return root;
}
describe("renderConfigYml", () => {
it("emits the exact top-level go-librespot keys/values", () => {
const lines = renderConfigYml(OPTS).split("\n");
expect(lines).toContain('device_name: "TeamSpeak Music Bot"');
expect(lines).toContain("device_type: computer");
expect(lines).toContain("bitrate: 320");
expect(lines).toContain("audio_backend: pipe");
expect(lines).toContain("audio_output_pipe: /tmp/go-librespot.fifo");
expect(lines).toContain("audio_output_pipe_format: s16le");
});
it("emits a server block with enabled/address/port set explicitly", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed.server).toEqual({
enabled: true,
address: "0.0.0.0",
port: 3678,
});
});
it("emits interactive OAuth credentials with the callback port", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed.credentials).toEqual({
type: "interactive",
interactive: { callback_port: 8080 },
});
});
it("full round-trip reflects every provided option", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed).toEqual({
device_name: "TeamSpeak Music Bot",
device_type: "computer",
bitrate: 320,
audio_backend: "pipe",
audio_output_pipe: "/tmp/go-librespot.fifo",
audio_output_pipe_format: "s16le",
server: { enabled: true, address: "0.0.0.0", port: 3678 },
credentials: { type: "interactive", interactive: { callback_port: 8080 } },
});
});
it("threads distinct option values through unchanged (no hard-coded ports)", () => {
const parsed = parseTinyYaml(
renderConfigYml({
deviceName: "Other Bot",
bitrate: 160,
fifoPath: "/run/librespot/pipe",
apiAddress: "127.0.0.1",
apiPort: 4000,
callbackPort: 9099,
}),
);
expect(parsed).toMatchObject({
device_name: "Other Bot",
bitrate: 160,
audio_output_pipe: "/run/librespot/pipe",
server: { address: "127.0.0.1", port: 4000 },
credentials: { interactive: { callback_port: 9099 } },
});
});
it("safely quotes device names containing special characters", () => {
const yml = renderConfigYml({ ...OPTS, deviceName: 'My "Cool" Bot' });
expect(yml.split("\n")).toContain('device_name: "My \\"Cool\\" Bot"');
// and still round-trips back to the original string
expect(parseTinyYaml(yml).device_name).toBe('My "Cool" Bot');
});
});
+42
View File
@@ -0,0 +1,42 @@
// src/music/spotify/go-librespot-config.ts
// Hand-built go-librespot config.yml. Keys/values verified against
// devgianlu/go-librespot cmd/daemon/cli_config.go koanf tags. No yaml
// dependency is used; the file is a small, fixed-shape document.
export interface GoLibrespotConfigOptions {
deviceName: string;
bitrate: number;
fifoPath: string;
apiAddress: string;
apiPort: number;
callbackPort: number;
}
/**
* Render a headless go-librespot config.yml:
* - pipe audio backend writing raw 44.1kHz/s16le stereo PCM to a FIFO,
* - HTTP+WebSocket control server enabled (port has NO built-in default,
* so it is always written explicitly),
* - interactive OAuth credentials (persisted automatically to
* <config_dir>/credentials.json after first login).
*/
export function renderConfigYml(o: GoLibrespotConfigOptions): string {
return (
[
`device_name: ${JSON.stringify(o.deviceName)}`,
`device_type: computer`,
`bitrate: ${o.bitrate}`,
`audio_backend: pipe`,
`audio_output_pipe: ${o.fifoPath}`,
`audio_output_pipe_format: s16le`,
`server:`,
` enabled: true`,
` address: ${o.apiAddress}`,
` port: ${o.apiPort}`,
`credentials:`,
` type: interactive`,
` interactive:`,
` callback_port: ${o.callbackPort}`,
].join("\n") + "\n"
);
}
+411
View File
@@ -0,0 +1,411 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { GoLibrespotBackend } from "./go-librespot.js";
const log = pino({ level: "silent" });
/** A minimal stand-in for a spawned ChildProcess with real Readable stdout/stderr. */
function makeFakeChild() {
const child: any = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
return child;
}
function makeHarness(portOpts: { apiPort?: number; callbackPort?: number } = {}) {
const calls: string[] = [];
const ffmpegChild = makeFakeChild();
const gliChild = makeFakeChild();
const spawn = vi.fn((cmd: string, ..._rest: any[]) => {
const isGli = cmd.includes("go-librespot");
calls.push(`spawn:${isGli ? "go-librespot" : cmd}`);
return isGli ? gliChild : ffmpegChild;
});
const execFileSync = vi.fn((cmd: string) => {
calls.push(`exec:${cmd}`);
return Buffer.from("");
});
const writeFileSync = vi.fn(() => calls.push("write:config"));
const mkdirSync = vi.fn();
const unlinkSync = vi.fn(() => calls.push("unlink:fifo"));
const existsSync = vi.fn(() => false);
const rest = {
ping: vi.fn(async () => true),
playTrack: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
seek: vi.fn(async () => {}),
getStatus: vi.fn(async () => null),
};
const events: any = new EventEmitter();
events.start = vi.fn(() => calls.push("ws:start"));
events.stop = vi.fn();
const backend = new GoLibrespotBackend({
deviceName: "Test Bot",
bitrate: 320,
workDir: "/tmp/work",
configDir: "/tmp/cfg",
apiPort: portOpts.apiPort ?? 3678,
callbackPort: portOpts.callbackPort,
logger: log,
deps: {
spawn,
execFileSync,
writeFileSync,
mkdirSync,
unlinkSync,
existsSync,
// C1: pin the ffmpeg command so the arg-array/order assertions below stay
// stable while production resolves ffmpeg via getFfmpegCommand() (which
// falls back to bundled ffmpeg-static when `ffmpeg` isn't on PATH).
ffmpegCommand: "ffmpeg",
findBinary: () => "/bin/go-librespot",
makeRest: () => rest,
makeEvents: () => events,
sleep: async () => {},
pollIntervalMs: 1,
pollTimeoutMs: 100,
} as any,
});
return { backend, calls, spawn, execFileSync, writeFileSync, existsSync, unlinkSync, rest, events, ffmpegChild, gliChild };
}
describe("GoLibrespotBackend.start", () => {
it("creates the FIFO with mkfifo before spawning ffmpeg, and spawns ffmpeg BEFORE go-librespot", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.execFileSync).toHaveBeenCalledWith("mkfifo", ["/tmp/work/go-librespot.fifo"]);
expect(h.writeFileSync).toHaveBeenCalled();
const mkfifoIdx = h.calls.indexOf("exec:mkfifo");
const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg");
const gliIdx = h.calls.indexOf("spawn:go-librespot");
expect(mkfifoIdx).toBeGreaterThanOrEqual(0);
expect(ffmpegIdx).toBeGreaterThan(mkfifoIdx); // ffmpeg attaches to the FIFO first
expect(gliIdx).toBeGreaterThan(ffmpegIdx); // then the writer (go-librespot)
expect(h.calls.indexOf("ws:start")).toBeGreaterThan(gliIdx); // WS connects last
});
it("passes --config_dir to go-librespot using the resolved binary path", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.spawn).toHaveBeenCalledWith(
"/bin/go-librespot",
["--config_dir", "/tmp/cfg"],
expect.anything(),
);
});
it("uses the 44100->48000 s16le ffmpeg command reading the FIFO", async () => {
const h = makeHarness();
await h.backend.start();
const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[];
expect(ffmpegArgs).toEqual([
"-hide_banner", "-loglevel", "error",
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "/tmp/work/go-librespot.fifo",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
]);
});
it("emits 'ready' and reports isReady() true once the REST ping succeeds", async () => {
const h = makeHarness();
const ready = vi.fn();
h.backend.on("ready", ready);
await h.backend.start();
expect(h.rest.ping).toHaveBeenCalled();
expect(ready).toHaveBeenCalledTimes(1);
expect(h.backend.isReady()).toBe(true);
});
it("keeps polling ping() until it returns true", async () => {
const h = makeHarness();
h.rest.ping.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
await h.backend.start();
expect(h.rest.ping).toHaveBeenCalledTimes(3);
expect(h.backend.isReady()).toBe(true);
});
});
describe("GoLibrespotBackend config binding (Fix 1 loopback + Fix 3 ports)", () => {
function writtenYml(h: ReturnType<typeof makeHarness>): string {
const calls = h.writeFileSync.mock.calls as unknown as any[][];
const call = calls.find((c) => String(c[0]).endsWith("config.yml"));
expect(call).toBeDefined();
return call![1] as string;
}
it("binds the control API to loopback (127.0.0.1), never 0.0.0.0", async () => {
const h = makeHarness();
await h.backend.start();
const yml = writtenYml(h);
expect(yml).toContain("address: 127.0.0.1");
expect(yml).not.toContain("0.0.0.0");
});
it("threads apiPort + callbackPort into the rendered config", async () => {
const h = makeHarness({ apiPort: 3712, callbackPort: 8712 });
await h.backend.start();
const yml = writtenYml(h);
expect(yml).toContain("port: 3712");
expect(yml).toContain("callback_port: 8712");
});
it("defaults callbackPort to 8080 when unset", async () => {
const h = makeHarness();
await h.backend.start();
expect(writtenYml(h)).toContain("callback_port: 8080");
});
});
describe("GoLibrespotBackend WebSocket event mapping", () => {
it("maps a not_playing event to trackEnded{reason:'ended'}", async () => {
const h = makeHarness();
await h.backend.start();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("not_playing", { uri: "spotify:track:abc" });
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:abc", reason: "ended" });
});
it("maps a stopped event to trackEnded{reason:'stopped'}", async () => {
const h = makeHarness();
await h.backend.start();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("stopped", { uri: "spotify:track:xyz" });
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:xyz", reason: "stopped" });
});
it("maps a metadata event to a SpotifyNowPlaying and updates getPositionMs()", async () => {
const h = makeHarness();
await h.backend.start();
const meta = vi.fn();
h.backend.on("metadata", meta);
h.events.emit("metadata", {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "http://x/y.jpg",
position: 1234,
duration: 200000,
});
expect(meta).toHaveBeenCalledWith({
uri: "spotify:track:abc",
name: "Song",
artist: "A, B",
album: "Alb",
coverUrl: "http://x/y.jpg",
durationMs: 200000,
});
expect(h.backend.getPositionMs()).toBe(1234);
});
});
describe("GoLibrespotBackend transport delegation + PCM", () => {
it("playTrack delegates to the REST client", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:go");
expect(h.rest.playTrack).toHaveBeenCalledWith("spotify:track:go");
});
it("pause/resume/seek delegate to the REST client and seek updates position", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.pause();
await h.backend.resume();
await h.backend.seek(5000);
expect(h.rest.pause).toHaveBeenCalled();
expect(h.rest.resume).toHaveBeenCalled();
expect(h.rest.seek).toHaveBeenCalledWith(5000);
expect(h.backend.getPositionMs()).toBe(5000);
});
it("getPcmStream() returns the ffmpeg stdout Readable", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout);
});
});
describe("GoLibrespotBackend.stop", () => {
it("kills ffmpeg + go-librespot, stops the WS, removes the FIFO, and clears ready", async () => {
const h = makeHarness();
await h.backend.start();
h.existsSync.mockReturnValue(true); // FIFO now present, so stop() unlinks it
h.backend.stop();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.gliChild.kill).toHaveBeenCalled();
expect(h.events.stop).toHaveBeenCalled();
expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo");
expect(h.backend.isReady()).toBe(false);
});
});
describe("GoLibrespotBackend.start failure cleanup", () => {
it("tears down ffmpeg, go-librespot, and the FIFO when readiness polling never succeeds", async () => {
const h = makeHarness();
// ping() never returns true → waitUntilReady() times out → start() rejects
// AFTER both processes were spawned and the FIFO was created.
h.rest.ping.mockResolvedValue(false);
// FIFO present so the cleanup path unlinks it.
h.existsSync.mockReturnValue(true);
await expect(h.backend.start()).rejects.toThrow(/did not become ready/);
expect(h.ffmpegChild.kill).toHaveBeenCalled(); // ffmpeg killed on failed startup
expect(h.gliChild.kill).toHaveBeenCalled(); // go-librespot killed on failed startup
expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); // FIFO removed
expect(h.backend.isReady()).toBe(false);
});
});
describe("GoLibrespotBackend child-process error handling", () => {
it("does not throw when a child 'error' is emitted with no backend 'error' listener attached", async () => {
const h = makeHarness();
await h.backend.start();
// No "error" listener on the backend: an unhandled 'error' event would crash
// Node, so the backend must swallow+log it instead of re-emitting.
expect(h.backend.listenerCount("error")).toBe(0);
expect(() => h.ffmpegChild.emit("error", new Error("ffmpeg boom"))).not.toThrow();
expect(() => h.gliChild.emit("error", new Error("gli boom"))).not.toThrow();
});
it("re-emits a child 'error' to an attached backend 'error' listener", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const err = new Error("ffmpeg boom");
h.ffmpegChild.emit("error", err);
expect(onErr).toHaveBeenCalledWith(err);
});
});
// Let queued microtasks (the async reconnect re-sync) settle.
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
describe("GoLibrespotBackend R4-2: unexpected sidecar exit recovery", () => {
it("degrades to trackEnded{reason:'error'}, surfaces 'error', and stops the WS on an UNEXPECTED exit", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur"); // sets the current uri
const ended = vi.fn();
const onErr = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("error", onErr);
// Sidecar dies under us (nonzero exit, no stop() from us).
h.gliChild.emit("exit", 1, null);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:cur", reason: "error" });
expect(onErr).toHaveBeenCalledTimes(1); // controller told -> rebuilds on next ensureStarted
expect(h.events.stop).toHaveBeenCalled(); // WS reconnect loop stopped (no dead-port hammer)
expect(h.backend.isReady()).toBe(false);
});
it("emits trackEnded BEFORE error so a listener that tears down still receives the skip", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
const order: string[] = [];
h.backend.on("trackEnded", () => order.push("trackEnded"));
h.backend.on("error", () => order.push("error"));
h.gliChild.emit("exit", null, "SIGKILL");
expect(order).toEqual(["trackEnded", "error"]);
});
it("a stop()-initiated exit emits NEITHER trackEnded NOR error", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
const ended = vi.fn();
const onErr = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("error", onErr);
h.backend.stop(); // intentional teardown -> the ensuing 'exit' must be silent
h.gliChild.emit("exit", 0, "SIGTERM");
expect(ended).not.toHaveBeenCalled();
expect(onErr).not.toHaveBeenCalled();
});
});
describe("GoLibrespotBackend R4-3: WS reconnect re-sync", () => {
const notPlaying = { stopped: true, paused: false, buffering: false, track: null };
const stillPlaying = {
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:cur",
name: "Song",
artist_names: ["A"],
album_name: "Alb",
album_cover_url: null,
position: 1000,
duration: 200000,
},
};
it("re-queries GET /status on reconnect and emits trackEnded when playback is no longer active", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(notPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
expect(h.rest.getStatus).toHaveBeenCalled();
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:cur", reason: "ended" });
});
it("does NOT emit a spurious trackEnded on reconnect when status shows still-playing", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(stillPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
expect(h.rest.getStatus).toHaveBeenCalled();
expect(ended).not.toHaveBeenCalled();
});
it("re-sync is idempotent: a reconnect then a live not_playing emits trackEnded only once", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(notPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
// A duplicate live event for the same track must not double-advance the queue.
h.events.emit("not_playing", { uri: "spotify:track:cur" });
expect(ended).toHaveBeenCalledTimes(1);
});
});
+402
View File
@@ -0,0 +1,402 @@
import { EventEmitter } from "node:events";
// The go-librespot sidecar is Linux/Docker-gated (mkfifo + Linux-only binary),
// so the FIFO and config-dir paths are ALWAYS POSIX. Use posix.join so the
// separators are correct on the Linux target regardless of the host OS.
import { posix as posixPath } from "node:path";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import {
execFileSync as realExecFileSync,
spawn as realSpawn,
} from "node:child_process";
import {
existsSync as realExistsSync,
mkdirSync as realMkdirSync,
unlinkSync as realUnlinkSync,
writeFileSync as realWriteFileSync,
} from "node:fs";
import type { Logger } from "pino";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { findGoLibrespot } from "./binary.js";
import { renderConfigYml } from "./go-librespot-config.js";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
import { getFfmpegCommand } from "../../audio/player.js";
export interface GoLibrespotBackendOptions {
deviceName: string;
bitrate: number;
workDir: string;
configDir: string;
apiPort?: number;
callbackPort?: number;
logger: Logger;
deps?: GoLibrespotBackendDeps;
}
/** Injectable seams so the whole lifecycle is testable without a real binary/FIFO/network. */
export interface GoLibrespotBackendDeps {
spawn?: typeof realSpawn;
execFileSync?: typeof realExecFileSync;
existsSync?: typeof realExistsSync;
mkdirSync?: typeof realMkdirSync;
unlinkSync?: typeof realUnlinkSync;
writeFileSync?: typeof realWriteFileSync;
findBinary?: () => string;
/**
* C1: override the ffmpeg command. Production resolves it via
* getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on
* PATH, the Docker case); tests pin it to "ffmpeg" for stable arg assertions.
*/
ffmpegCommand?: string;
makeRest?: (baseUrl: string) => GoLibrespotRestClient;
makeEvents?: (wsUrl: string) => GoLibrespotEventClient;
sleep?: (ms: number) => Promise<void>;
pollIntervalMs?: number;
pollTimeoutMs?: number;
}
const DEFAULT_API_PORT = 3678;
const DEFAULT_CALLBACK_PORT = 8080;
const FIFO_NAME = "go-librespot.fifo";
export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend {
private readonly opts: GoLibrespotBackendOptions;
private readonly log: Logger;
private readonly deps: GoLibrespotBackendDeps;
private readonly apiPort: number;
private readonly callbackPort: number;
private readonly fifoPath: string;
private ffmpeg: ChildProcess | null = null;
private proc: ChildProcess | null = null;
private rest: GoLibrespotRestClient | null = null;
private events: GoLibrespotEventClient | null = null;
private ready = false;
private positionMs = 0;
// R4-2: distinguishes an INTENTIONAL teardown (stop()/failed start()) — where a
// sidecar exit is expected and must be silent — from an UNEXPECTED death that
// must degrade-to-skip + surface an error so the controller relaunches.
private stopping = false;
// The uri of the track currently loaded in the sidecar (set by playTrack and
// refreshed from metadata). Used as the trackEnded uri on an unexpected death
// (R4-2) and on reconnect reconciliation (R4-3).
private currentUri = "";
// At-most-one trackEnded per track. Set when we emit a track-end, cleared when
// a new track begins. Keeps the reconnect re-sync (R4-3) from double-emitting
// with a live not_playing/stopped event.
private endLatched = false;
constructor(o: GoLibrespotBackendOptions) {
super();
this.opts = o;
this.log = o.logger;
this.deps = o.deps ?? {};
this.apiPort = o.apiPort ?? DEFAULT_API_PORT;
this.callbackPort = o.callbackPort ?? DEFAULT_CALLBACK_PORT;
this.fifoPath = posixPath.join(o.workDir, FIFO_NAME);
}
async start(): Promise<void> {
// Fresh lifecycle (also covers a start() after a previous stop() on the same
// instance): clear the teardown flag and per-track end state.
this.stopping = false;
this.currentUri = "";
this.endLatched = false;
const spawn = this.deps.spawn ?? realSpawn;
const execFileSync = this.deps.execFileSync ?? realExecFileSync;
const existsSync = this.deps.existsSync ?? realExistsSync;
const mkdirSync = this.deps.mkdirSync ?? realMkdirSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
const writeFileSync = this.deps.writeFileSync ?? realWriteFileSync;
const findBinary = this.deps.findBinary ?? findGoLibrespot;
// C1: resolve ffmpeg via the repo's getFfmpegCommand() (ffmpeg-static
// fallback) unless a command is injected for tests.
const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand();
// 1. Ensure work + config directories exist.
mkdirSync(this.opts.workDir, { recursive: true });
mkdirSync(this.opts.configDir, { recursive: true });
// 2. (Re)create the FIFO — mkfifo fails if the path already exists.
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
execFileSync("mkfifo", [this.fifoPath]);
// Everything past this point spawns processes / opens sockets. If any step
// throws (e.g. the readiness poll times out), tear down whatever was already
// created via stop() (kill ffmpeg + go-librespot, close WS, remove FIFO) so
// we don't leak child processes or leave the FIFO on disk, then rethrow.
try {
// 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before
// go-librespot (the writer) starts pushing raw 44.1k s16le into it.
// Opening the FIFO for writing before a reader exists errors with ENXIO.
this.ffmpeg = spawn(
ffmpegCommand,
[
"-hide_banner", "-loglevel", "error",
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
this.ffmpeg.stderr?.on("data", (b: Buffer) =>
this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"),
);
this.ffmpeg.on("error", (err) => this.emitError(err));
// 4. Render + write config.yml into the config dir.
const yml = renderConfigYml({
deviceName: this.opts.deviceName,
bitrate: this.opts.bitrate,
fifoPath: this.fifoPath,
// The go-librespot control API is UNAUTHENTICATED and the client only
// ever connects via 127.0.0.1; the sidecar runs in the SAME container
// as the bot, so bind to loopback rather than exposing it on 0.0.0.0.
apiAddress: "127.0.0.1",
apiPort: this.apiPort,
callbackPort: this.callbackPort,
});
writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8");
// 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/
// stderr carry the interactive OAuth URL on first run — surface via logger.
const bin = findBinary();
this.proc = spawn(bin, ["--config_dir", this.opts.configDir], {
stdio: ["ignore", "pipe", "pipe"],
});
const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot");
this.proc.stdout?.on("data", onLog);
this.proc.stderr?.on("data", onLog);
this.proc.on("error", (err) => this.emitError(err));
this.proc.on("exit", (code, signal) => {
this.ready = false;
this.log.warn({ code, signal }, "go-librespot exited");
// R4-2: a stop()-initiated exit is expected — stay silent. Any other exit
// is the sidecar dying under us and must be recovered.
if (this.stopping) return;
this.onUnexpectedExit(code, signal);
});
// 6. REST client, then poll GET / until the HTTP server answers.
const baseUrl = `http://127.0.0.1:${this.apiPort}`;
this.rest = this.deps.makeRest
? this.deps.makeRest(baseUrl)
: new GoLibrespotRestClient(baseUrl);
await this.waitUntilReady();
// 7. Connect the WebSocket event stream and wire event mapping.
const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`;
this.events = this.deps.makeEvents
? this.deps.makeEvents(wsUrl)
: new GoLibrespotEventClient(wsUrl);
this.wireEvents(this.events);
this.events.start();
this.ready = true;
this.emit("ready");
} catch (e) {
this.stop();
throw e;
}
}
/**
* Re-emit a child-process "error" only when a consumer is listening; Node
* throws on an unhandled "error" event (can crash the process), so with no
* listener we log via the injected logger instead. Mirrors the WS client's
* listenerCount("error") gate in go-librespot-api.ts.
*/
private emitError(err: unknown): void {
if (this.listenerCount("error") > 0) {
this.emit("error", err);
} else {
this.log.error({ err }, "go-librespot backend error (no listener)");
}
}
/**
* At-most-one track-end per track. The WS not_playing/stopped events, the
* unexpected-death degrade (R4-2) and the reconnect re-sync (R4-3) all funnel
* through here so a track can only advance the queue once.
*/
private emitTrackEnded(e: SpotifyTrackEndedEvent): void {
if (this.endLatched) return;
this.endLatched = true;
this.emit("trackEnded", e);
}
/**
* R4-2: the go-librespot sidecar died without a stop() from us. Spotify
* auto-advance is driven ONLY by the WS trackEnded event (the player-side stall
* watchdog is disabled in external mode), so a silent death would stall the
* queue forever. Mirror the Rust I4 degrade-to-skip:
* (a) emit trackEnded{reason:"error"} so BotInstance advances the queue;
* (b) stop the WS reconnect loop so it stops hammering the now-dead API port;
* (c) surface via emitError so the controller tears down + relaunches a fresh
* backend on the next ensureStarted().
* trackEnded MUST be emitted BEFORE emitError: the controller's error handler
* removeAllListeners() on teardown, so a trackEnded emitted after would be lost.
*/
private onUnexpectedExit(code: number | null, signal: NodeJS.Signals | null): void {
// (b) Kill the reconnect loop first — the API port is dead.
try {
this.events?.stop();
} catch {
/* ignore */
}
this.events = null;
// (a) Degrade-to-skip only if a track was actually loaded; a death during
// startup with nothing playing has no queue item to advance.
if (this.currentUri) {
this.emitTrackEnded({ uri: this.currentUri, reason: "error" });
}
// (c) Surface so the controller rebuilds.
this.emitError(
new Error(
`go-librespot exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`,
),
);
}
/**
* R4-3: the WS reconnected after a drop. go-librespot only pushes events (it is
* never polled after startup), so a not_playing/stopped emitted during the
* down window is lost and the queue would stall. Re-query GET /status and, if
* playback is no longer active (the track ended in the gap), emit trackEnded so
* the queue advances. Idempotent via the end-latch (no double-emit with a live
* event). Only fired on a real reconnect — never the initial connect.
*/
private async reconcileAfterReconnect(): Promise<void> {
const rest = this.rest;
if (!rest) return;
const status = await rest.getStatus();
// Couldn't read status — don't guess a track-end.
if (!status) return;
const active = status.track != null && !status.stopped;
if (!active) {
this.emitTrackEnded({ uri: this.currentUri, reason: "ended" });
}
}
private async waitUntilReady(): Promise<void> {
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const interval = this.deps.pollIntervalMs ?? 200;
const timeout = this.deps.pollTimeoutMs ?? 15_000;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (this.rest && (await this.rest.ping())) return;
await sleep(interval);
}
throw new Error("go-librespot API did not become ready within timeout");
}
private wireEvents(ev: GoLibrespotEventClient): void {
ev.on("metadata", (d: any) => {
const np: SpotifyNowPlaying = {
uri: typeof d?.uri === "string" ? d.uri : "",
name: typeof d?.name === "string" ? d.name : "",
artist: Array.isArray(d?.artist_names) ? d.artist_names.join(", ") : "",
album: typeof d?.album_name === "string" ? d.album_name : "",
coverUrl: typeof d?.album_cover_url === "string" ? d.album_cover_url : "",
durationMs: typeof d?.duration === "number" ? d.duration : 0,
};
if (typeof d?.position === "number") this.positionMs = d.position;
// A new track is now playing: remember it (for R4-2/R4-3) and clear the
// per-track end-latch so its eventual end can advance the queue.
if (np.uri && np.uri !== this.currentUri) {
this.currentUri = np.uri;
this.endLatched = false;
}
this.emit("metadata", np);
});
ev.on("seek", (d: any) => {
if (typeof d?.position === "number") this.positionMs = d.position;
});
ev.on("not_playing", (d: any) => {
this.emitTrackEnded({ uri: typeof d?.uri === "string" ? d.uri : "", reason: "ended" });
});
ev.on("stopped", (d: any) => {
this.emitTrackEnded({ uri: typeof d?.uri === "string" ? d.uri : "", reason: "stopped" });
});
// R4-3: reconcile any track-end missed while the WS was down.
ev.on("reconnected", () => {
void this.reconcileAfterReconnect();
});
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
if (!this.rest) throw new Error("go-librespot backend not started");
// Track what is loaded so an unexpected death (R4-2) / reconnect re-sync
// (R4-3) can advance the queue for the right uri, and reset the end-latch.
this.currentUri = uri;
this.endLatched = false;
await this.rest.playTrack(uri);
}
async pause(): Promise<void> {
if (this.rest) await this.rest.pause();
}
async resume(): Promise<void> {
if (this.rest) await this.rest.resume();
}
async seek(ms: number): Promise<void> {
if (this.rest) await this.rest.seek(ms);
this.positionMs = ms;
}
getPcmStream(): Readable {
const out = this.ffmpeg?.stdout;
if (!out) throw new Error("PCM stream unavailable (go-librespot backend not started)");
return out;
}
getPositionMs(): number {
return this.positionMs;
}
stop(): void {
// R4-2: mark this an INTENTIONAL teardown so the go-librespot 'exit' handler
// (fired by the SIGTERM below) stays silent instead of degrading-to-skip.
this.stopping = true;
this.ready = false;
try {
this.events?.stop();
} catch {
/* ignore */
}
this.events = null;
this.rest = null;
if (this.proc) {
try {
this.proc.kill("SIGTERM");
} catch {
/* ignore */
}
this.proc = null;
}
if (this.ffmpeg) {
try {
this.ffmpeg.kill("SIGTERM");
} catch {
/* ignore */
}
this.ffmpeg = null;
}
const existsSync = this.deps.existsSync ?? realExistsSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
try {
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
} catch {
/* ignore */
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { SpotifyProvider } from "./provider.js";
import { SpotifyWebApi } from "./webapi.js";
function fakeApi(over: Partial<SpotifyWebApi> = {}): SpotifyWebApi {
return {
hasCreds: () => true,
setCreds: vi.fn(),
search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }),
getTrack: vi.fn().mockResolvedValue(null),
getAlbumTracks: vi.fn().mockResolvedValue([]),
getPlaylistTracks: vi.fn().mockResolvedValue([]),
...over,
} as unknown as SpotifyWebApi;
}
describe("SpotifyProvider", () => {
it("has platform 'spotify'", () => {
expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify");
});
it("getSongUrl returns the spotify: sentinel, not a real URL", async () => {
const p = new SpotifyProvider(fakeApi());
const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh");
expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" });
});
it("search delegates to the web API", async () => {
const api = fakeApi({
search: vi.fn().mockResolvedValue({
songs: [{ id: "t1", platform: "spotify" }],
playlists: [],
albums: [],
}),
});
const out = await new SpotifyProvider(api).search("queen", 5);
expect(api.search).toHaveBeenCalledWith("queen", 5);
expect(out.songs[0].id).toBe("t1");
});
it("getAuthStatus reflects credential presence", async () => {
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true);
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false);
});
it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => {
const api = fakeApi({
getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]),
getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]),
});
const p = new SpotifyProvider(api);
expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p");
expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a");
});
it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => {
const p = new SpotifyProvider(fakeApi());
expect(await p.getLyrics("x")).toEqual([]);
expect(await p.getRecommendPlaylists()).toEqual([]);
expect((await p.getQrCode()).key).toBe("");
expect(await p.checkQrCodeStatus("k")).toBe("expired");
});
});
+86
View File
@@ -0,0 +1,86 @@
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
Album,
SearchResult,
LyricLine,
QrCodeResult,
AuthStatus,
} from "../provider.js";
import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js";
export class SpotifyProvider implements MusicProvider {
readonly platform = "spotify" as const;
private api: SpotifyWebApi;
private creds: SpotifyCreds = { clientId: "", clientSecret: "" };
private quality = "320";
constructor(api?: SpotifyWebApi) {
this.api = api ?? new SpotifyWebApi(() => this.creds);
}
setCreds(clientId: string, clientSecret: string): void {
this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" };
this.api.setCreds(this.creds);
}
async search(query: string, limit = 20): Promise<SearchResult> {
return this.api.search(query, limit);
}
// Stage 1: return a sentinel URI. The play path recognizes `spotify:` and
// skips with a "not playable yet" message; real audio arrives in Stage 2/3.
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
return { url: `spotify:track:${songId}` };
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
return this.api.getTrack(songId);
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
return this.api.getPlaylistTracks(playlistId);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
return this.api.getAlbumTracks(albumId);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return this.api.hasCreds()
? { loggedIn: true, nickname: "Spotify" }
: { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" };
}
}
+814
View File
@@ -0,0 +1,814 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { RustLibrespotBackend, type RustLibrespotBackendDeps } from "./rust-librespot.js";
const log = pino({ level: "silent" });
/** ChildProcess stand-in with real Readable/Writable pipes so stdout->stdin piping works. */
function makeFakeChild() {
const child: any = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.kill = vi.fn();
return child;
}
function makeConnect() {
return {
getDevices: vi.fn(async () => [{ id: "dev1", name: "Test Bot", is_active: false }]),
findDeviceByName: vi.fn(async () => "dev1"),
transfer: vi.fn(async () => {}),
play: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
seek: vi.fn(async () => {}),
getPlaybackState: vi.fn(async () => null as any),
};
}
function makeOAuth() {
return {
getAccessToken: vi.fn(async () => "tok-123" as string | null),
isAuthorized: () => true,
};
}
function makeHarness(
over: { connect?: any; oauth?: any; deps?: Partial<RustLibrespotBackendDeps> } = {},
) {
const calls: string[] = [];
const librespotChild = makeFakeChild();
const ffmpegChild = makeFakeChild();
const spawn = vi.fn((cmd: string, ..._rest: any[]) => {
const isLibrespot = cmd.includes("librespot");
calls.push(`spawn:${isLibrespot ? "librespot" : cmd}`);
return isLibrespot ? librespotChild : ffmpegChild;
});
const mkdirSync = vi.fn();
const connect = over.connect ?? makeConnect();
const oauth = over.oauth ?? makeOAuth();
const backend = new RustLibrespotBackend({
deviceName: "Test Bot",
bitrate: 320,
cacheDir: "/tmp/cache",
oauth: oauth as any,
connect: connect as any,
logger: log,
deps: {
spawn: spawn as any,
mkdirSync: mkdirSync as any,
findBinary: () => "/bin/librespot",
// C1: pin ffmpeg so arg-array assertions stay stable while prod uses getFfmpegCommand().
ffmpegCommand: "ffmpeg",
sleep: async () => {},
readyPollIntervalMs: 1,
readyTimeoutMs: 100,
// huge so the background setInterval never fires; tests drive pollState() directly.
statePollIntervalMs: 10_000_000,
...over.deps,
},
});
return { backend, calls, spawn, mkdirSync, connect, oauth, librespotChild, ffmpegChild };
}
describe("RustLibrespotBackend.start", () => {
it("spawns librespot with the pipe/stdout arg set and the OAuth access token", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.spawn).toHaveBeenCalledWith(
"/bin/librespot",
[
"--name", "Test Bot",
"--backend", "pipe",
"--bitrate", "320",
"--format", "S16",
"--cache", "/tmp/cache",
"--device-type", "speaker",
"--access-token", "tok-123",
],
expect.anything(),
);
// NO --device (=> stdout) and NO --passthrough (=> decoded PCM, not Ogg).
const args = h.spawn.mock.calls.find((c) => String(c[0]).includes("librespot"))![1] as string[];
expect(args).not.toContain("--device");
expect(args).not.toContain("--passthrough");
h.backend.stop();
});
it("spawns ffmpeg (reader) before librespot (writer) with the exact 44100->48000 s16le args", async () => {
const h = makeHarness();
await h.backend.start();
const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[];
expect(ffmpegArgs).toEqual([
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
]);
const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg");
const librespotIdx = h.calls.indexOf("spawn:librespot");
expect(ffmpegIdx).toBeGreaterThanOrEqual(0);
expect(librespotIdx).toBeGreaterThan(ffmpegIdx);
h.backend.stop();
});
it("getPcmStream() returns the ffmpeg stdout Readable", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout);
h.backend.stop();
});
it("emits 'ready' and reports isReady() true once our device appears in getDevices()", async () => {
const h = makeHarness();
const ready = vi.fn();
h.backend.on("ready", ready);
await h.backend.start();
expect(h.connect.getDevices).toHaveBeenCalled();
expect(ready).toHaveBeenCalledTimes(1);
expect(h.backend.isReady()).toBe(true);
h.backend.stop();
});
it("keeps polling getDevices() until the device name appears", async () => {
const h = makeHarness();
h.connect.getDevices
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "other", name: "Someone else", is_active: true }])
.mockResolvedValue([{ id: "dev1", name: "Test Bot", is_active: false }]);
await h.backend.start();
expect(h.connect.getDevices).toHaveBeenCalledTimes(3);
expect(h.backend.isReady()).toBe(true);
h.backend.stop();
});
it("throws (and does not spawn) when the OAuth token is null", async () => {
const oauth = makeOAuth();
oauth.getAccessToken.mockResolvedValue(null);
const h = makeHarness({ oauth });
await expect(h.backend.start()).rejects.toThrow(/authorized|token/i);
expect(h.spawn).not.toHaveBeenCalled();
});
});
describe("RustLibrespotBackend transport delegation (Connect API)", () => {
it("playTrack resolves the device then transfer(false) then play(uri)", async () => {
const h = makeHarness();
await h.backend.playTrack("spotify:track:go");
expect(h.connect.findDeviceByName).toHaveBeenCalledWith("Test Bot");
expect(h.connect.transfer).toHaveBeenCalledWith("dev1", false);
expect(h.connect.play).toHaveBeenCalledWith("dev1", "spotify:track:go");
// ordering: transfer before play
expect(h.connect.transfer.mock.invocationCallOrder[0])
.toBeLessThan(h.connect.play.mock.invocationCallOrder[0]);
});
it("playTrack throws when the device cannot be found", async () => {
const h = makeHarness();
h.connect.findDeviceByName.mockResolvedValue(null);
await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i);
});
// R4-4 (multi-bot): control must be scoped to OUR device. playTrack resolves
// and stores our device id (dev1); pause/resume/seek then pass it to the
// Connect API so bot A's pause/resume/seek can't act on bot B's playback (the
// account-wide default would pause whatever device is currently active).
it("pause/resume/seek delegate to the Connect API scoped to OUR device, and seek updates position", async () => {
const h = makeHarness();
await h.backend.playTrack("spotify:track:go"); // stores our device id (dev1)
await h.backend.pause();
await h.backend.resume();
await h.backend.seek(5000);
expect(h.connect.pause).toHaveBeenCalledWith("dev1");
expect(h.connect.resume).toHaveBeenCalledWith("dev1");
expect(h.connect.seek).toHaveBeenCalledWith(5000, "dev1");
expect(h.backend.getPositionMs()).toBe(5000);
});
});
describe("RustLibrespotBackend track-end poll loop", () => {
it("emits trackEnded when progress reaches the end-of-track window", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Arm detection the way production does — via our own playTrack().
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState();
expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:A", durationMs: 200000 }));
expect(h.backend.getPositionMs()).toBe(1000);
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// C1(pause-skip): a USER pause reports is_playing:false with the SAME uri on
// the Rust backend. That MUST NOT be read as a track end (it would skip the
// paused track and break pause + occupancy auto-pause). Formerly the
// "!isPlaying after having played" test asserted the opposite — that encoded
// the bug; it is now split into this pause-no-skip test plus the two-poll
// external-stop test below.
it("does NOT emit trackEnded when the user PAUSES (self-initiated pause is not a track end)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User pauses: the Connect device stays loaded but reports is_playing:false
// with the SAME uri across every subsequent poll while paused.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple polls
expect(ended).not.toHaveBeenCalled();
// Resuming keeps the same track playing — still no spurious end.
await h.backend.resume();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("emits trackEnded once on an EXTERNAL stop only after TWO consecutive !isPlaying polls (a transient mid-track !isPlaying is not a skip)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // observed playing
await (h.backend as any).pollState(); // FIRST !isPlaying -> unconfirmed (could be transient buffering)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // SECOND consecutive !isPlaying -> confirmed external stop
await (h.backend as any).pollState(); // idempotent: no second emit for same track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("a transient single !isPlaying poll followed by playing again does NOT emit trackEnded (buffering hiccup)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // playing
await (h.backend as any).pollState(); // momentary !isPlaying (buffering)
await (h.backend as any).pollState(); // playing again -> stop confirmation reset
expect(ended).not.toHaveBeenCalled();
});
// m(sub-window): a track SHORTER than the end-of-track window must not be
// declared finished on its first observed-playing poll (durationMs - window
// is negative, so the old near-end check fired unconditionally).
it("does NOT false-finish a sub-window (< END_OF_TRACK_WINDOW_MS) duration on the first playing poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:short");
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 100, trackUri: "spotify:track:short", durationMs: 1200,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
// C1(pause-skip) residual: a self-initiated pause within the FINAL
// END_OF_TRACK_WINDOW_MS freezes progress at >= dur-window with is_playing:false
// and the SAME uri. The finishedByProgress near-end heuristic must NOT fire
// while paused (it would skip the paused track). After resume(), the track
// plays on and finishes exactly once.
it("does NOT skip when the user PAUSES within the final end-of-track window, but ends once after resume", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first (mid-track).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User pauses within the final ~1.5s: frozen progress >= dur-window,
// is_playing:false, SAME uri, across every subsequent paused poll.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple polls
expect(ended).not.toHaveBeenCalled();
// Resume -> the track plays on to its natural end and finishes exactly once.
await h.backend.resume();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// C1(pause-skip) residual: during a LONG self-initiated pause the Connect
// device can idle out to a 204 / null playback state. That null state while
// paused must NOT be read as a track end (it would skip the paused track).
// After resume() a genuinely dead device (persistent null) IS detected.
it("does NOT skip a PAUSED track when the device idles out to null/204, but DOES after resume", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// Pause, then the Connect device idles out to a 204 / null state.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue(null as any);
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple null polls
expect(ended).not.toHaveBeenCalled();
// Resume -> a genuinely dead device (persistent null) is now detected once.
await h.backend.resume();
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// Regression: a NON-paused track that idles out to a null/204 state after
// having played must STILL emit exactly one trackEnded (the pause gate must
// not suppress genuine device death for a non-paused track).
it("regression: a NON-paused track that idles out to null/204 after playing still emits exactly one trackEnded", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing, not paused
h.connect.getPlaybackState.mockResolvedValue(null as any); // device idles out
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // idempotent: no second emit
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// R3-1: a GENUINE end where the item stays null across TWO consecutive
// non-paused polls still emits exactly one trackEnded. The null-item path now
// shares the external-stop two-poll confirmation, so a single transient null
// (Connect handoff / market relink) no longer skips a still-playing track —
// but a persistent null is still detected. (Previously this test asserted a
// single null poll ended the track; that encoded the R3-1 corner-case bug.)
it("emits trackEnded once when the track uri stays null across TWO consecutive polls after playing", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 });
await (h.backend as any).pollState(); // observed playing our uri
await (h.backend as any).pollState(); // FIRST null item -> unconfirmed (could be transient)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // SECOND consecutive null -> confirmed end
await (h.backend as any).pollState(); // idempotent: no second emit for same track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// R3-1: Spotify legitimately returns item:null transiently at a track/Connect
// handoff boundary, and getPlaybackState omits the `market` param so a
// region-relinked/restricted item can momentarily map to uri:null. A SINGLE
// {isPlaying:true, trackUri:null} poll mid-track must NOT skip a still-playing
// track; a following poll showing our real uri again confirms it kept playing.
it("a transient single null-item poll (isPlaying:true, trackUri:null) followed by our uri again does NOT emit trackEnded", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 })
.mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // playing our uri
await (h.backend as any).pollState(); // momentary null item (handoff / market relink)
await (h.backend as any).pollState(); // our uri again -> confirmation reset, still playing
expect(ended).not.toHaveBeenCalled();
});
// R3-1 (pause invariant): a self-paused track must NEVER skip. A {trackUri:null}
// poll while WE hold a pause must not be read as a track end (same invariant
// the finishedByStop / null-204 paths already enforce with a `!paused` guard).
it("does NOT emit trackEnded on a null-item poll while the track is self-paused", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing
await h.backend.pause();
// While paused, a null item appears across multiple polls (state present, no item).
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 5000, trackUri: null, durationMs: 0,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("ignores a null playback state (no active device) without emitting", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.connect.getPlaybackState.mockResolvedValue(null);
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("C3.4: a startup poll before playTrack never emits (foreign track near its end)", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Backend started, but playTrack has NOT been called => detection disarmed.
await h.backend.start();
// First poll observes a FOREIGN track that is actively playing near its end.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true,
progressMs: 199000,
trackUri: "spotify:foreign",
durationMs: 200000,
});
await (h.backend as any).pollState();
// No spurious end-of-track and no bogus metadata before the bot ever plays.
expect(ended).not.toHaveBeenCalled();
expect(meta).not.toHaveBeenCalled();
expect(h.backend.getPositionMs()).toBe(0);
h.backend.stop();
});
it("after playTrack, a normal finish emits trackEnded exactly once for our uri", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
// Arm detection via our own play, then confirm-then-finish our uri.
await h.backend.playTrack("spotify:track:ours");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:ours", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:ours", durationMs: 200000 });
await (h.backend as any).pollState(); // confirms our uri playing
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // finishes
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" });
});
});
// R4-4 (multi-bot): config.spotify (and thus the Connect session) is shared by
// every bot under one Premium account, which supports only ONE active playback
// stream. GET /v1/me/player is account-wide, so once bot B steals the active
// session our poll would see B's device + B's track. The backend now stores OUR
// device id and, when the reported activeDeviceId differs, refuses to treat the
// foreign playback as ours: no foreign metadata, no misattribution — the stolen
// session instead advances OUR queue cleanly via the existing two-poll stop.
describe("RustLibrespotBackend multi-bot device scoping (R4-4)", () => {
it("ignores foreign-device poll state: no foreign metadata, no misattribution, and a stolen session advances OUR queue once", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Our track plays on OUR device (dev1 — the findDeviceByName mock id).
await h.backend.playTrack("spotify:track:ours");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:ours",
durationMs: 200000, activeDeviceId: "dev1",
});
await (h.backend as any).pollState(); // our track observed playing on our device
expect(meta).toHaveBeenCalledTimes(1);
expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:ours" }));
expect(h.backend.getPositionMs()).toBe(5000);
meta.mockClear();
// Bot B steals the single active Connect session: /v1/me/player now reports
// THEIR device (dev2) + THEIR track, and would keep doing so every poll.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 123000, trackUri: "spotify:track:foreign",
durationMs: 200000, activeDeviceId: "dev2",
});
await (h.backend as any).pollState(); // FIRST foreign poll -> unconfirmed stop, no side effects
expect(meta).not.toHaveBeenCalled(); // foreign metadata NOT surfaced as ours
expect(ended).not.toHaveBeenCalled(); // two-poll confirmation not met yet
expect(h.backend.getPositionMs()).toBe(5000); // foreign progress NOT misattributed
await (h.backend as any).pollState(); // SECOND foreign poll -> confirmed -> OUR queue advances
await (h.backend as any).pollState(); // idempotent: no second emit for our track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" });
expect(meta).not.toHaveBeenCalled(); // never emitted metadata for the foreign uri
});
it("when the active device IS ours (activeDeviceId === our id), end-detection behaves exactly as today", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" });
await (h.backend as any).pollState(); // confirms our uri playing on our device
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // near-end -> finishes normally
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
});
// R4-6: finishedByProgress is a SINGLE-poll near-end heuristic. If a user
// deliberately SEEKS to within the final END_OF_TRACK_WINDOW_MS, the next poll
// would see progressMs >= durationMs-1500 and emit trackEnded — skipping the ~1s
// the user seeked into. A one-poll "just-seeked" grace suppresses that single
// misfire; the track then plays its remaining <=1.5s and ends naturally on the
// following poll via the stop/null detection. Natural near-end (reached by
// PLAYING, no seek) must be UNCHANGED.
describe("RustLibrespotBackend seek-into-end grace (R4-6)", () => {
it("does NOT skip when a seek lands within the final end-of-track window; ends naturally on the following poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first (mid-track).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User deliberately seeks to ~1s before the end (inside the 1.5s window).
await h.backend.seek(199000);
// First poll after the seek: progress is already inside the near-end window
// and still playing. The grace must suppress this single finishedByProgress.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// The remaining <=1.5s plays out and librespot goes idle (null/204) — the
// genuine end. It emits exactly one trackEnded.
h.connect.getPlaybackState.mockResolvedValue(null as any);
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("the seek grace only spans ONE poll: a second in-window playing poll still finishes by progress", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing
await h.backend.seek(199000); // seek into the final window
// Grace poll: suppressed.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// Second in-window playing poll: grace already consumed -> natural near-end
// detection fires exactly once (grace must not permanently disable it).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199500, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("regression: a track that reaches the final window by PLAYING (no seek) still emits trackEnded on the first in-window poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // observed playing (no seek)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // reaches window by PLAYING -> natural end
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
});
describe("RustLibrespotBackend playback-start watchdog (I4 degrade-to-skip)", () => {
/** A controllable timer seam matching the file's injected-deps style. */
function makeFakeTimer() {
const pending: Array<{ cb: () => void; ms: number; handle: object }> = [];
const setTimer = vi.fn((cb: () => void, ms: number) => {
const handle = {};
pending.push({ cb, ms, handle });
return handle;
});
const clearTimer = vi.fn((h: unknown) => {
const i = pending.findIndex((p) => p.handle === h);
if (i >= 0) pending.splice(i, 1);
});
// "advance fake timers": run (and drain) every armed callback.
const advance = () => pending.splice(0).forEach((p) => p.cb());
return { setTimer, clearTimer, advance, pending };
}
it("emits exactly ONE trackEnded{reason:'error'} when playback never starts", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
// The connect layer NEVER reports our track playing (204 / idle).
h.connect.getPlaybackState.mockResolvedValue(null);
await h.backend.playTrack("spotify:track:stuck");
// Polls that never observe playback must NOT emit anything on their own.
await (h.backend as any).pollState();
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// The watchdog is armed exactly once; advance past playbackStartTimeoutMs.
expect(timer.pending).toHaveLength(1);
expect(timer.pending[0].ms).toBe(8000);
timer.advance();
// Degraded-to-skip: one trackEnded with reason "error" for our uri.
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:stuck", reason: "error" });
// A late idle poll after the watchdog fired does not double-emit.
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
h.backend.stop();
});
it("does NOT fire the watchdog when the device actually starts playing", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:ok");
// Our device reports the track actually playing -> watchdog is disarmed.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true,
progressMs: 1000,
trackUri: "spotify:track:ok",
durationMs: 200000,
});
await (h.backend as any).pollState();
// Real playback observed -> the watchdog was cleared, not left armed.
expect(timer.clearTimer).toHaveBeenCalled();
expect(timer.pending).toHaveLength(0);
// Even if a stale timer somehow fired, no "error" end must be emitted.
timer.advance();
expect(ended).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: "error" }),
);
h.backend.stop();
});
it("a new playTrack() cancels the previous track's watchdog (at-most-one per track)", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.connect.getPlaybackState.mockResolvedValue(null);
await h.backend.playTrack("spotify:track:one");
await h.backend.playTrack("spotify:track:two");
// The first track's watchdog was cleared; only the second remains armed.
expect(timer.clearTimer).toHaveBeenCalled();
expect(timer.pending).toHaveLength(1);
timer.advance();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:two", reason: "error" });
h.backend.stop();
});
});
describe("RustLibrespotBackend.stop", () => {
it("kills librespot + ffmpeg, clears ready, and is idempotent", async () => {
const h = makeHarness();
await h.backend.start();
h.backend.stop();
h.backend.stop(); // second call must not throw
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
});
});
describe("RustLibrespotBackend.start failure cleanup", () => {
it("tears down librespot + ffmpeg when the device never appears", async () => {
const h = makeHarness();
h.connect.getDevices.mockResolvedValue([]); // device never shows up -> waitForDevice times out
await expect(h.backend.start()).rejects.toThrow(/did not appear/i);
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
});
});
describe("RustLibrespotBackend child-process error handling", () => {
it("swallows+logs a child 'error' when no backend 'error' listener is attached", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.listenerCount("error")).toBe(0);
expect(() => h.librespotChild.emit("error", new Error("boom"))).not.toThrow();
expect(() => h.ffmpegChild.emit("error", new Error("boom"))).not.toThrow();
h.backend.stop();
});
it("re-emits a child 'error' to an attached backend 'error' listener", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const err = new Error("ffmpeg boom");
h.ffmpegChild.emit("error", err);
expect(onErr).toHaveBeenCalledWith(err);
h.backend.stop();
});
// I(pipe): ffmpeg dying mid-track while librespot keeps producing PCM raises
// EPIPE on ffmpeg.stdin. With no stdin 'error' listener Node escalates it to
// process 'uncaughtException'. The backend must handle it in-band.
it("swallows an EPIPE 'error' on ffmpeg.stdin (ffmpeg died mid-track) without an unhandled throw", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.listenerCount("error")).toBe(0);
const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow();
h.backend.stop(); // idempotent second teardown must not throw
});
it("routes an ffmpeg.stdin EPIPE to the backend 'error' listener and tears down cleanly", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow();
expect(onErr).toHaveBeenCalledWith(epipe);
// Broken pipe -> clean teardown: children killed, not ready.
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
h.backend.stop(); // second teardown must not throw (no double-teardown crash)
expect(onErr).toHaveBeenCalledTimes(1); // single emit despite both pipe ends
});
it("does not throw when librespot proc.stdout emits an EPIPE on the broken pipe", async () => {
const h = makeHarness();
await h.backend.start();
const epipe = Object.assign(new Error("read/write EPIPE"), { code: "EPIPE" });
expect(() => h.librespotChild.stdout.emit("error", epipe)).not.toThrow();
h.backend.stop();
});
});
+620
View File
@@ -0,0 +1,620 @@
import { EventEmitter } from "node:events";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import { spawn as realSpawn } from "node:child_process";
import { mkdirSync as realMkdirSync } from "node:fs";
import type { Logger } from "pino";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { findLibrespot } from "./binary.js";
import { SpotifyConnectApi } from "./connect-api.js";
import type { PlaybackState, SpotifyDevice } from "./connect-api.js";
import type { SpotifyOAuth } from "./spotify-oauth.js";
import { getFfmpegCommand } from "../../audio/player.js";
export interface RustLibrespotBackendOptions {
deviceName: string;
bitrate: number;
cacheDir: string;
oauth: SpotifyOAuth;
connect?: SpotifyConnectApi;
logger: Logger;
deps?: RustLibrespotBackendDeps;
}
/** Injectable seams so the whole lifecycle is testable without a real binary/network. */
export interface RustLibrespotBackendDeps {
spawn?: typeof realSpawn;
mkdirSync?: typeof realMkdirSync;
findBinary?: () => string;
/**
* C1: override the ffmpeg command. Production resolves it via
* getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on
* PATH); tests pin it to "ffmpeg" for stable arg assertions.
*/
ffmpegCommand?: string;
sleep?: (ms: number) => Promise<void>;
readyPollIntervalMs?: number;
readyTimeoutMs?: number;
statePollIntervalMs?: number;
/**
* I4 (§13): bounded window after playTrack() within which our own track must
* be observed playing, else we degrade-to-skipped (emit trackEnded
* reason:"error"). Injectable timer seams (default real setTimeout, unref'd)
* let tests advance it deterministically.
*/
playbackStartTimeoutMs?: number;
setTimeout?: (cb: () => void, ms: number) => unknown;
clearTimeout?: (handle: unknown) => void;
}
const DEFAULT_READY_POLL_MS = 500;
const DEFAULT_READY_TIMEOUT_MS = 20_000;
const DEFAULT_STATE_POLL_MS = 2_000;
/** How close to the end (ms) counts as "track finished" when polling player state. */
const END_OF_TRACK_WINDOW_MS = 1_500;
/**
* I4 (§13): if our own track has not been observed playing within this window
* after playTrack(), degrade-to-skipped so the queue advances instead of
* stalling on a persistently-failing play (403 non-Premium, 404 outliving
* retries). Bounded and injectable for tests.
*/
const DEFAULT_PLAYBACK_START_TIMEOUT_MS = 8_000;
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBackend {
private readonly opts: RustLibrespotBackendOptions;
private readonly log: Logger;
private readonly deps: RustLibrespotBackendDeps;
private readonly oauth: SpotifyOAuth;
private readonly connect: SpotifyConnectApi;
private proc: ChildProcess | null = null;
private ffmpeg: ChildProcess | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
// I4 (§13): "did our track actually start playing?" watchdog handle (opaque —
// produced by the injectable setTimeout seam). null when disarmed.
private watchdogTimer: unknown = null;
private ready = false;
private positionMs = 0;
// R4-4 (multi-bot): OUR resolved Connect device id, captured from
// findDeviceByName() in playTrack(). config.spotify (hence this Connect
// session) is shared process-wide across every BotInstance under one Premium
// account, and both the control API (pause/resume/seek) and the state read
// (GET /v1/me/player) are ACCOUNT-wide by default. Persisting our device id
// lets us (a) device-scope our control commands so we only ever act on our own
// device, and (b) in pollState, ignore state reported for a foreign device
// another bot stole the single active session with. null until first playTrack.
private deviceId: string | null = null;
// track-end poll state machine
private currentUri: string | null = null;
private hasPlayed = false;
private endedForCurrent = false;
// C3.4: end-detection is DISARMED until our own playTrack() runs. A poll that
// fires before the bot ever asks to play (e.g. at startup) must produce no
// side effects at all, so a foreign track already near its end can't emit a
// spurious trackEnded/metadata and wrongly advance the queue.
private armed = false;
// C1(pause-skip): our own pause state. A self-initiated pause makes the
// Connect device report is_playing:false with the SAME uri; without tracking
// it we'd misread that as a track end and SKIP the paused track (breaking the
// pause command and occupancy auto-pause-when-alone on the Rust backend). Set
// by pause(), cleared by resume() and playTrack().
private paused = false;
// Robustness (finishedByStop + finishedByNull / R3-1): a "not clearly playing
// our track" signal — either a non-paused is_playing:false (external stop) OR
// a null item (trackUri===null) observed while our track was playing — must be
// confirmed across TWO consecutive non-paused polls before we emit trackEnded.
// Both signals can be momentary: is_playing:false from a buffering hiccup; a
// null item from a Connect/track handoff boundary or a region-relinked /
// restricted item that momentarily maps to uri:null (getPlaybackState omits
// the `market` param). Set on the first such poll; reset only when a poll
// clearly shows our track playing again (is_playing AND a real item), on track
// change, or on playTrack(). Sharing ONE flag keeps both sibling paths from
// false-skipping on a single transient poll.
private stopSeen = false;
// R4-6: one-poll "just-seeked" grace. finishedByProgress is a SINGLE-poll
// near-end heuristic; if a user deliberately seeks to within the final
// END_OF_TRACK_WINDOW_MS, the very next poll would see progressMs >=
// durationMs-window and emit trackEnded, SKIPPING the ~1s the user seeked
// into. Set by seek(); consumed on the next poll so it suppresses that single
// finishedByProgress misfire only — the track then plays its remaining <=1.5s
// and ends naturally on the following poll via the stop/null detection. It
// deliberately does NOT touch the paused/stop/null two-poll logic, and a track
// reaching the window by PLAYING (no preceding seek) still ends normally.
private seekGrace = false;
// I(pipe): one teardown per broken librespot->ffmpeg pipe. Both stream ends
// (ffmpeg.stdin write side, proc.stdout read side) can report the same EPIPE;
// this guard prevents a double teardown / double error-emit.
private pipeBroke = false;
constructor(o: RustLibrespotBackendOptions) {
super();
this.opts = o;
this.log = o.logger;
this.deps = o.deps ?? {};
this.oauth = o.oauth;
// The Connect API shares the backend's OAuth token source. Reuse the
// injected instance in tests; otherwise build one over oauth.getAccessToken().
this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken());
}
async start(): Promise<void> {
const spawn = this.deps.spawn ?? realSpawn;
const mkdirSync = this.deps.mkdirSync ?? realMkdirSync;
const findBinary = this.deps.findBinary ?? findLibrespot;
// C1: resolve ffmpeg via getFfmpegCommand() unless injected for tests.
const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand();
// A valid USER control token is required before we spawn anything.
const token = await this.oauth.getAccessToken();
if (!token) {
throw new Error("Spotify not authorized (no access token) — sign in first");
}
mkdirSync(this.opts.cacheDir, { recursive: true });
// Everything past here spawns children / opens the state poll. On any
// failure (e.g. the device never appears), tear it all down via stop().
try {
this.pipeBroke = false; // fresh pipe for this start()
// 1. Spawn ffmpeg FIRST (the reader) so its stdin pipe is ready before
// librespot starts pushing raw 44.1k s16le PCM into it.
this.ffmpeg = spawn(
ffmpegCommand,
[
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
],
{ stdio: ["pipe", "pipe", "pipe"] },
);
this.ffmpeg.stderr?.on("data", (b: Buffer) =>
this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"),
);
this.ffmpeg.on("error", (err) => this.emitError(err));
// I(pipe): if ffmpeg dies mid-track while librespot keeps producing PCM,
// the next write into its stdin hits the now-closed pipe -> EPIPE 'error'
// on stdin. With no listener Node escalates that to process
// 'uncaughtException' (the global handler logs but leaves undefined
// state). Handle it in-band: tear down cleanly and surface via emitError.
this.ffmpeg.stdin?.on("error", (err) => this.onPipeError(err));
// 2. Spawn librespot: --backend pipe with NO --device => raw s16le/44100/2
// on stdout, NO --passthrough (that would emit raw Ogg). --access-token
// authenticates it as a Connect device controllable via the Web API.
const bin = findBinary();
this.proc = spawn(
bin,
[
"--name", this.opts.deviceName,
"--backend", "pipe",
"--bitrate", String(this.opts.bitrate),
"--format", "S16",
"--cache", this.opts.cacheDir,
"--device-type", "speaker",
// KNOWN LIMITATION (CWE-214): the live Spotify access token is passed in
// the child argv, so on a shared/multi-tenant host a co-located local
// process could read it via `ps` / /proc/<pid>/cmdline. Bounded (~1h token,
// needs local access) and `--access-token` is librespot's supported bootstrap.
"--access-token", token,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
// stdout carries PCM — pipe it, never attach a data listener that consumes it.
if (this.proc.stdout && this.ffmpeg.stdin) {
this.proc.stdout.pipe(this.ffmpeg.stdin);
// Guard the read side too: a broken pipe can surface as an 'error' on
// proc.stdout when ffmpeg's stdin closes underneath it. Same handler,
// guarded against a double teardown.
this.proc.stdout.on("error", (err) => this.onPipeError(err));
}
this.proc.stderr?.on("data", (b: Buffer) =>
this.log.info({ librespot: b.toString().trim() }, "librespot"),
);
this.proc.on("error", (err) => this.emitError(err));
this.proc.on("exit", (code, signal) => {
this.ready = false;
this.log.warn({ code, signal }, "librespot exited");
});
// 3. Poll the Connect device list until our device registers.
await this.waitForDevice();
// 4. Begin the player-state poll loop (track-end / position / metadata).
this.startPollLoop();
this.ready = true;
this.emit("ready");
} catch (e) {
this.stop();
throw e;
}
}
/**
* Re-emit a child "error" only when a consumer is listening; Node throws on an
* unhandled "error" event, so with no listener we log via the injected logger.
*/
private emitError(err: unknown): void {
if (this.listenerCount("error") > 0) {
this.emit("error", err);
} else {
this.log.error({ err }, "rust-librespot backend error (no listener)");
}
}
/**
* I(pipe): the librespot->ffmpeg PCM pipe broke — typically ffmpeg died
* mid-track and librespot's next write hit the closed pipe (EPIPE), or the
* stream was destroyed (ERR_STREAM_DESTROYED). Both are expected teardown
* signals, not programming errors. Log, tear down cleanly (stop() is
* idempotent), then surface via emitError so a listening controller reacts.
* Guarded so both stream ends reporting the same break don't double-teardown.
*/
private onPipeError(err: unknown): void {
if (this.pipeBroke) return;
this.pipeBroke = true;
this.log.warn(
{ err },
"rust-librespot: librespot->ffmpeg pipe broke (ffmpeg died?) — tearing down",
);
this.stop();
this.emitError(err);
}
private async waitForDevice(): Promise<void> {
const sleep = this.deps.sleep ?? defaultSleep;
const interval = this.deps.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS;
const timeout = this.deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
let devices: SpotifyDevice[] = [];
try {
devices = await this.connect.getDevices();
} catch (err) {
this.log.debug({ err }, "getDevices failed during readiness poll");
}
if (devices.some((d) => d.name === this.opts.deviceName)) return;
await sleep(interval);
}
throw new Error(`librespot device "${this.opts.deviceName}" did not appear within timeout`);
}
private startPollLoop(): void {
const interval = this.deps.statePollIntervalMs ?? DEFAULT_STATE_POLL_MS;
this.pollTimer = setInterval(() => {
void this.pollState();
}, interval);
// Don't keep the event loop / test process alive on account of the poll timer.
this.pollTimer.unref?.();
}
/** One player-state poll iteration: updates position/metadata and detects track end. */
private async pollState(): Promise<void> {
let state: PlaybackState | null;
try {
state = await this.connect.getPlaybackState();
} catch (err) {
this.log.debug({ err }, "getPlaybackState failed");
return;
}
// C3.4: detection is armed ONLY by our own playTrack(). Until then a poll
// must have NO side effects — no metadata/trackEnded emit and no mutation of
// currentUri/hasPlayed/positionMs. This single gate covers the null-state
// (C3.5 post-play-204) branch, the metadata-emit block, and every end
// heuristic below, so a foreign track sitting near its end at startup can
// never spuriously advance the queue. (Device-readiness polling via
// getDevices is separate and stays active regardless of this flag.)
if (!this.armed) return;
// R4-6: consume the one-poll "just-seeked" grace up front so it spans exactly
// ONE poll regardless of which branch this poll takes. `justSeeked` then
// suppresses a single finishedByProgress misfire below (a deliberate seek
// into the final END_OF_TRACK_WINDOW_MS must not be read as a natural end).
const justSeeked = this.seekGrace;
this.seekGrace = false;
if (!state) {
// C3.5: a 204 / no-active-device response. AFTER our own track has been
// seen playing, librespot going idle means the track ended — emit once so
// the queue advances instead of stalling. BEFORE any play (hasPlayed
// false), a null state is just "nothing active yet" and is ignored.
// C1(pause-skip) residual: while WE hold a self-initiated pause, a long
// pause can let the Connect device idle out to 204/null. That is still
// "paused", NOT a track end — do nothing this poll, or we'd skip the
// paused track. Once resume() clears `paused`, a genuinely-dead device
// (persistent null) is detected and skipped on the next poll as before.
if (this.hasPlayed && this.currentUri && !this.endedForCurrent && !this.paused) {
this.endedForCurrent = true;
const endedUri = this.currentUri;
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
this.emit("trackEnded", e);
}
return;
}
// R4-4 (multi-bot): is the account's single ACTIVE Connect device ours? The
// Premium account allows one active playback stream, so if another bot stole
// the session, GET /v1/me/player now reports THAT device + its track. Only
// when the active device is foreign (we know our id AND the state names a
// DIFFERENT active id) do we refuse to treat this poll as our own track:
// skip metadata/track-change (so B's now-playing isn't surfaced as ours),
// don't advance our position/hasPlayed off foreign playback, and feed the
// stop/null two-poll detection so the stolen session cleanly ends OUR track
// and advances OUR queue instead of thrashing/misattributing. LENIENT: if
// our id is unknown or activeDeviceId is absent we can't tell, so we fall
// back to today's behavior byte-for-byte (single-bot: activeDeviceId === ours).
const foreignActive =
this.deviceId != null &&
state.activeDeviceId != null &&
state.activeDeviceId !== this.deviceId;
// Don't misattribute a foreign device's playback position as ours.
if (!foreignActive) this.positionMs = state.progressMs;
// Track change -> reset the end-detection state and surface best-effort
// metadata. Skipped entirely when a foreign device is active: its uri is NOT
// our track, so adopting it / emitting metadata would surface another bot's
// now-playing as ours and later misread that bot's stop as our track's end.
if (!foreignActive && state.trackUri && state.trackUri !== this.currentUri) {
this.currentUri = state.trackUri;
this.hasPlayed = false;
this.endedForCurrent = false;
this.stopSeen = false; // new track -> drop any pending stop confirmation
const np: SpotifyNowPlaying = {
uri: state.trackUri,
name: "",
artist: "",
album: "",
coverUrl: "",
durationMs: state.durationMs,
};
this.emit("metadata", np);
}
// R4-4: only OUR device actually playing counts as our track playing. When a
// foreign device is active, `state.isPlaying` reflects THAT bot's playback,
// not ours — so it must not mark hasPlayed or clear the stop confirmation.
const ourTrackPlaying = state.isPlaying && !foreignActive;
if (ourTrackPlaying) {
this.hasPlayed = true;
// Real playback observed -> the I4 degrade-to-skip watchdog is moot.
this.clearPlaybackWatchdog();
}
// Clearly playing our track again (is_playing AND a real item) -> any earlier
// transient is_playing:false (buffering) or null item (Connect handoff /
// market relink) was a hiccup; drop the pending two-poll end confirmation.
// A null item under is_playing:true is NOT "clearly playing" and must NOT
// reset the confirmation, or a genuine null-item end could never accumulate
// its second poll.
if (ourTrackPlaying && state.trackUri !== null) {
this.stopSeen = false;
}
if (!this.currentUri || this.endedForCurrent) return;
// C3.4: EVERY end condition is gated on hasPlayed so no end can fire until
// the bot's own track has actually been observed playing. Without this, the
// first poll (before playTrack) could observe the account's stale/paused
// track sitting near its end and spuriously emit "trackEnded".
// m(sub-window): only apply the near-end window when the track is LONGER
// than the window. For durationMs in [1, END_OF_TRACK_WINDOW_MS],
// `durationMs - window` is negative, so the old `> 0` guard made this
// unconditionally true and finished the track on its first poll. A
// sub-window track instead relies on normal stop/next-track detection.
// C1(pause-skip) residual: a self-initiated pause within the final window
// freezes progress at >= dur-window with is_playing:false and the SAME uri;
// without `!this.paused` this near-end heuristic would fire and skip the
// paused track. resume() clears `paused`, so a genuine natural end is still
// detected afterwards.
// R4-4: progress/duration under a foreign-active state belong to another
// bot's track, so the near-end heuristic must not fire off them. A stolen
// session ends OUR track through the stop/null two-poll path below instead.
const finishedByProgress =
this.hasPlayed &&
!this.paused &&
!foreignActive &&
state.durationMs > END_OF_TRACK_WINDOW_MS &&
state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS;
// C1(pause-skip) + R3-1(null-item): a self-initiated pause (this.paused)
// reports is_playing:false (and can idle to a null item) with the SAME uri —
// that is NOT a track end, so never finish while paused. Beyond pause, the
// two "not clearly playing our track" signals — an external stop
// (is_playing:false) and a null item (trackUri===null) — are BOTH momentary
// at the boundaries (buffering; Connect handoff / market relink), so they
// share ONE two-poll confirmation (stopSeen): require the signal to persist
// across two consecutive non-paused polls before ending. A single transient
// stop OR null is absorbed; a confirmed end still emits within ~one extra
// poll interval, and a genuine end (item stays null / stopped across two
// polls) still fires exactly once.
// R4-4: a foreign device becoming the active one means OUR device is no
// longer playing our track — the same signal as an external stop / null
// item, so it shares the two-poll confirmation: one foreign poll is
// unconfirmed (could be a transient handoff), two consecutive foreign polls
// cleanly end our track and advance our queue.
let finishedByStopOrNull = false;
if (
this.hasPlayed &&
!this.paused &&
(foreignActive || !state.isPlaying || state.trackUri === null)
) {
if (this.stopSeen) {
finishedByStopOrNull = true;
} else {
this.stopSeen = true; // first such poll — await confirmation
}
}
// R4-6: a deliberate seek into the near-end window suppresses this single
// finishedByProgress. `justSeeked` was already consumed above, so the NEXT
// in-window poll finishes normally — the grace never permanently disables
// near-end detection, and it never touches the stop/null two-poll path.
if ((finishedByProgress && !justSeeked) || finishedByStopOrNull) {
this.endedForCurrent = true; // latch: emit at most once per track
const endedUri = this.currentUri;
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
this.emit("trackEnded", e);
}
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
const deviceId = await this.connect.findDeviceByName(this.opts.deviceName);
if (!deviceId) throw new Error(`Connect device "${this.opts.deviceName}" not found`);
// R4-4: persist OUR device id so control (pause/resume/seek) is device-scoped
// and pollState can distinguish our device from a foreign one that stole the
// shared account's single active Connect session.
this.deviceId = deviceId;
// Reset the track-end state machine for the new track: clear the once-only
// latch and drop hasPlayed so no end can fire until a poll re-confirms this
// uri playing. currentUri is cleared so the next poll re-detects the track
// (fresh metadata) rather than treating it as unchanged. Arming here is the
// primary guarantee that no end/metadata can fire before the bot plays.
// Cancel the previous track's degrade-to-skip watchdog first (at-most-one
// trackEnded per track, I4).
this.clearPlaybackWatchdog();
this.currentUri = null;
this.hasPlayed = false;
this.endedForCurrent = false;
this.armed = true;
// A newly-started track is not paused, and carries no pending stop
// confirmation from the previous track. (C1 pause-skip.)
this.paused = false;
this.stopSeen = false;
// R4-6: a fresh track carries no pending seek grace from the previous one.
this.seekGrace = false;
// transfer(false) activates our device WITHOUT starting audio; play() then
// actually starts the uri. The two-step is required — transfer alone won't
// begin playback.
await this.connect.transfer(deviceId, false);
await this.connect.play(deviceId, uri);
// I4 (§13): arm the "did it actually start playing?" watchdog. If our own
// track is never seen playing within the window, degrade-to-skipped so the
// queue advances instead of hanging on a silent, persistently-failing play.
this.armPlaybackWatchdog(uri);
}
/**
* I4 (§13) degrade-to-skip watchdog. Arms a bounded timer; if our own track
* has not been observed playing by the time it fires, emit a single
* trackEnded{reason:"error"} so the controller re-emits it and BotInstance
* advances the queue. Cleared when real playback is observed, on stop(), and
* at the start of a new playTrack().
*/
private armPlaybackWatchdog(uri: string): void {
this.clearPlaybackWatchdog();
const timeoutMs = this.deps.playbackStartTimeoutMs ?? DEFAULT_PLAYBACK_START_TIMEOUT_MS;
const setTimer =
this.deps.setTimeout ??
((cb: () => void, ms: number) => {
const t = setTimeout(cb, ms);
(t as { unref?: () => void }).unref?.();
return t;
});
this.watchdogTimer = setTimer(() => {
this.watchdogTimer = null;
this.onPlaybackStartTimeout(uri);
}, timeoutMs);
}
private clearPlaybackWatchdog(): void {
if (this.watchdogTimer == null) return;
const clearTimer =
this.deps.clearTimeout ??
((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
clearTimer(this.watchdogTimer);
this.watchdogTimer = null;
}
/**
* Fired when the playback-start window elapses with no observed playback.
* C3.6: never throws up the queue path. Guarded by the same endedForCurrent
* latch as normal end-detection so at most one trackEnded per track (no
* double-emit with the poll-loop end detection).
*/
private onPlaybackStartTimeout(uri: string): void {
// Real playback was seen (hasPlayed) or the track already ended -> moot.
if (this.hasPlayed || this.endedForCurrent) return;
this.endedForCurrent = true; // latch
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri, reason: "error" };
this.emit("trackEnded", e);
}
async pause(): Promise<void> {
// R4-4: device-scope to OUR device so bot A's pause / auto-pause-when-alone
// can't pause whatever device is currently active for the shared account
// (= bot B's playback). Falls back to account-wide only before first play.
await this.connect.pause(this.deviceId ?? undefined);
// C1(pause-skip): mark our own pause so the next poll's is_playing:false
// (same uri) is not misread as a track end and skipped.
this.paused = true;
}
async resume(): Promise<void> {
// R4-4: device-scope to OUR device (see pause()).
await this.connect.resume(this.deviceId ?? undefined);
// Resumed -> normal end-detection applies again.
this.paused = false;
}
async seek(ms: number): Promise<void> {
// R4-4: device-scope to OUR device (see pause()).
await this.connect.seek(ms, this.deviceId ?? undefined);
this.positionMs = ms;
// R4-6: arm the one-poll grace so a seek landing inside the final
// END_OF_TRACK_WINDOW_MS is not immediately treated as a natural end.
this.seekGrace = true;
}
getPcmStream(): Readable {
const out = this.ffmpeg?.stdout;
if (!out) throw new Error("PCM stream unavailable (rust-librespot backend not started)");
return out;
}
getPositionMs(): number {
return this.positionMs;
}
stop(): void {
this.ready = false;
// Disarm the I4 degrade-to-skip watchdog so a stopped backend never emits.
this.clearPlaybackWatchdog();
// Clear the state poll interval FIRST so no poll fires mid-teardown.
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
if (this.ffmpeg) {
try {
this.ffmpeg.kill();
} catch {
/* ignore */
}
this.ffmpeg = null;
}
}
}
+587
View File
@@ -0,0 +1,587 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
mkdtempSync,
rmSync,
readdirSync,
readFileSync,
renameSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SpotifyOAuth,
SPOTIFY_CONTROL_SCOPES,
generateCodeVerifier,
codeChallengeS256,
createFileOAuthTokenStore,
type OAuthTokens,
type OAuthTokenStore,
} from "./spotify-oauth.js";
// Wrap the fs functions the token store uses in call-through spies so the
// atomic-write path (temp file + rename) can be observed/forced. Everything else
// (mkdtemp, rmSync, readdir, …) is the real implementation via `...actual`, so
// all other tests keep 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,
writeFileSync: vi.fn(actual.writeFileSync),
renameSync: vi.fn(actual.renameSync),
};
});
// Correction C3.2: the control OAuth REQUIRES a user-provided client_id (their
// own Spotify Developer app) + caller-supplied loopback redirect. There is NO
// librespot public-client / :5588 default.
const CLIENT_ID = "test-client-id";
const REDIRECT_URI = "http://127.0.0.1:8888/api/spotify/callback";
/** In-memory store exposing `.value` so tests can assert persistence. */
function memStore(
initial: OAuthTokens | null = null,
): OAuthTokenStore & { value: OAuthTokens | null } {
const s = {
value: initial,
load() {
return s.value;
},
save(t: OAuthTokens) {
s.value = t;
},
clear() {
s.value = null;
},
};
return s;
}
/** A manually-settled promise, to hold a token POST "in flight" during a test. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("PKCE helpers", () => {
it("generateCodeVerifier returns 64 chars from the unreserved set", () => {
const v = generateCodeVerifier();
expect(v).toHaveLength(64);
expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/);
expect(generateCodeVerifier()).not.toBe(v); // random
});
it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => {
const c = codeChallengeS256("abc123");
expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars
expect(c).not.toContain("=");
expect(c).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe("SpotifyOAuth.buildAuthorizeUrl", () => {
it("builds accounts.spotify.com/authorize with the caller's clientId + redirectUri + S256", () => {
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
});
const { url, state } = oauth.buildAuthorizeUrl();
const u = new URL(url);
expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize");
const p = u.searchParams;
expect(p.get("client_id")).toBe(CLIENT_ID);
expect(p.get("response_type")).toBe("code");
expect(p.get("redirect_uri")).toBe(REDIRECT_URI);
expect(p.get("code_challenge_method")).toBe("S256");
expect(p.get("code_challenge")).toHaveLength(43);
expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES);
expect(p.get("state")).toBe(state);
expect(state).toMatch(/^[0-9a-f]{32}$/);
expect(oauth.getClientId()).toBe(CLIENT_ID);
expect(oauth.getRedirectUri()).toBe(REDIRECT_URI);
});
// Correction C3.2: no clientId => cannot start OAuth; throw a clear message.
it("throws a clear error when clientId is empty", () => {
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store: memStore() });
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("SpotifyOAuth.isAuthorized (C3.2)", () => {
it("is false without a clientId even if a refresh token is stored", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store });
expect(oauth.isAuthorized()).toBe(false);
});
it("is true with a clientId and a stored refresh token", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store });
expect(oauth.isAuthorized()).toBe(true);
});
});
describe("SpotifyOAuth.handleCallback", () => {
it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => {
const store = memStore();
const http = {
post: vi.fn().mockResolvedValue({
data: {
access_token: "a1",
refresh_token: "r1",
expires_in: 3600,
scope: SPOTIFY_CONTROL_SCOPES,
},
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store,
deps: { http },
});
const { url, state } = oauth.buildAuthorizeUrl();
const challenge = new URL(url).searchParams.get("code_challenge")!;
const ok = await oauth.handleCallback("CODE123", state);
expect(ok).toBe(true);
const [path, bodyStr, cfg] = http.post.mock.calls[0];
expect(path).toBe("/api/token");
expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(bodyStr as string);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("CODE123");
expect(body.get("redirect_uri")).toBe(REDIRECT_URI);
expect(body.get("client_id")).toBe(CLIENT_ID);
// The verifier sent MUST hash to the challenge advertised in the authorize URL.
const verifier = body.get("code_verifier")!;
expect(codeChallengeS256(verifier)).toBe(challenge);
expect(store.value?.accessToken).toBe("a1");
expect(store.value?.refreshToken).toBe("r1");
expect(store.value?.expiresAt).toBeGreaterThan(Date.now());
expect(oauth.isAuthorized()).toBe(true);
});
it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
// Correction C3.7: the state->verifier entry is deleted on EVERY terminal
// path (finally), so a failed login never leaks it and cannot be replayed.
it("deletes the pending verifier even when the token exchange fails", async () => {
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
const { state } = oauth.buildAuthorizeUrl();
// First attempt fails at the network/token step.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
// Replaying the same state now fails the CSRF guard (verifier was deleted),
// WITHOUT hitting the token endpoint again.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
describe("SpotifyOAuth.getAccessToken", () => {
it("returns the cached token without refreshing when still valid", async () => {
const http = { post: vi.fn() } as any;
const store = memStore({
accessToken: "cached",
refreshToken: "r1",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("cached");
expect(http.post).not.toHaveBeenCalled();
});
it("refreshes when expired and persists the ROTATED refresh token", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
const body = new URLSearchParams(http.post.mock.calls[0][1] as string);
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("r1");
expect(body.get("client_id")).toBe(CLIENT_ID);
expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted
expect(store.value?.accessToken).toBe("a2");
});
it("keeps the old refresh token when the refresh response omits a new one", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
expect(store.value?.refreshToken).toBe("r1");
});
it("clears the store and returns null on invalid_grant (expired refresh token)", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBeNull();
expect(store.value).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
});
it("returns null when unauthorized (no stored refresh token)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store: memStore(),
deps: { http },
});
expect(await oauth.getAccessToken()).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
});
// S4.3: refresh-token rotation makes two concurrent refreshes race — the second
// would POST with a token the first already invalidated. Collapse them into one.
describe("SpotifyOAuth.getAccessToken (in-flight refresh, S4.3)", () => {
it("collapses concurrent refreshes into a single token POST", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0, // now()=0 is not < 0 -> expired -> must refresh
scope: "s",
});
const d = deferred<any>();
const http = { post: vi.fn().mockReturnValue(d.promise) } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
// Fire two calls while the POST is still pending.
const p1 = oauth.getAccessToken();
const p2 = oauth.getAccessToken();
expect(http.post).toHaveBeenCalledTimes(1); // collapsed to ONE POST
d.resolve({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
});
expect(await p1).toBe("a2");
expect(await p2).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
expect(store.value?.refreshToken).toBe("r2"); // rotated once, not twice
});
it("clears the in-flight refresh after it settles, allowing a later refresh", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0,
scope: "s",
});
const http = {
post: vi
.fn()
.mockResolvedValueOnce({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
})
.mockResolvedValueOnce({
data: { access_token: "a3", refresh_token: "r3", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// toTokens now uses this.now(): saved expiresAt = 0 + 3600s - 30s skew.
// Still valid at t=0 -> cached, no new POST.
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// Advance past the newly-saved expiry -> in-flight was cleared, so a fresh
// refresh fires (proves .finally() reset refreshInFlight).
t = 3600 * 1000;
expect(await oauth.getAccessToken()).toBe("a3");
expect(http.post).toHaveBeenCalledTimes(2);
expect(store.value?.refreshToken).toBe("r3");
});
});
// S4.3: bound the PKCE verifier map so abandoned logins can't accumulate.
describe("SpotifyOAuth PKCE verifier TTL + cap (S4.3)", () => {
it("expires a pending verifier after the TTL (handleCallback returns false)", async () => {
let t = 0;
const now = () => t;
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
const { state } = oauth.buildAuthorizeUrl();
t = 10 * 60 * 1000 + 1; // VERIFIER_TTL_MS + 1
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).not.toHaveBeenCalled(); // never reached the token step
});
it("caps the verifier map, evicting the oldest state (behavioral)", async () => {
let t = 0;
const now = () => t;
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a1", refresh_token: "r1", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
// First (oldest) state, then enough more to exceed VERIFIER_MAX (32).
const first = oauth.buildAuthorizeUrl().state;
let last = first;
for (let i = 0; i < 32; i++) last = oauth.buildAuthorizeUrl().state;
// The oldest was evicted -> unknown state -> CSRF guard, no token POST.
expect(await oauth.handleCallback("CODE", first)).toBe(false);
expect(http.post).not.toHaveBeenCalled();
// A still-pending (newest) state DOES resolve -> only the oldest was dropped.
expect(await oauth.handleCallback("CODE", last)).toBe(true);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
// Whole-branch I2: a UI-entered Client ID (saved in Settings) must reach the
// single live SpotifyOAuth WITHOUT a process restart. configure() re-arms the
// runtime credentials; an empty clientId re-disables OAuth.
describe("SpotifyOAuth.configure (runtime credentials, whole-branch I2)", () => {
it("applies a UI-entered clientId + redirectUri so OAuth arms without a restart", () => {
// Fresh install: boot-time config had no clientId, so OAuth is disabled.
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: "", store });
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
// Operator enters creds in Settings -> POST /settings calls configure().
const REDIRECT = "http://127.0.0.1:3000/api/spotify/callback";
oauth.configure("cid", REDIRECT);
expect(oauth.getClientId()).toBe("cid");
expect(oauth.getRedirectUri()).toBe(REDIRECT);
// Now authorize + isAuthorized work against the newly-supplied app.
expect(oauth.isAuthorized()).toBe(true);
const { url } = oauth.buildAuthorizeUrl();
const p = new URL(url).searchParams;
expect(p.get("client_id")).toBe("cid");
expect(p.get("redirect_uri")).toBe(REDIRECT);
});
it("trims the clientId and re-disables OAuth when cleared", () => {
const oauth = new SpotifyOAuth({
clientId: "old",
redirectUri: "http://old",
store: memStore(),
});
oauth.configure(" cid ", "http://127.0.0.1:3000/api/spotify/callback");
expect(oauth.getClientId()).toBe("cid"); // trimmed
// Empty clientId disables OAuth again (gate on buildAuthorizeUrl/isAuthorized).
oauth.configure("");
expect(oauth.getClientId()).toBe("");
expect(oauth.getRedirectUri()).toBe("");
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("createFileOAuthTokenStore", () => {
it("round-trips save/load and clear() removes it", () => {
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-"));
const file = join(dir, "nested", "tokens.json");
try {
const store = createFileOAuthTokenStore(file);
expect(store.load()).toBeNull(); // missing file
const t: OAuthTokens = {
accessToken: "a",
refreshToken: "r",
expiresAt: 123,
scope: "s",
};
store.save(t);
expect(store.load()).toEqual(t);
store.clear();
expect(store.load()).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// R3-5: the token store save() must be atomic (same-dir temp file + rename), so
// a crash / power loss / ENOSPC while persisting a ROTATED refresh token (Spotify
// invalidates the OLD one the instant it responds — the new one lives only in
// memory until this write lands) can never truncate the file and silently
// de-authenticate the operator. Mirrors the hardened config.ts saveConfig.
describe("createFileOAuthTokenStore atomic write (R3-5)", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-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;
});
const TOK: OAuthTokens = {
accessToken: "a",
refreshToken: "r",
expiresAt: 123,
scope: "s",
};
it("round-trips save/load and leaves NO .tmp file behind", () => {
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
const store = createFileOAuthTokenStore(file);
store.save(TOK);
expect(store.load()).toEqual(TOK);
// 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 file = join(dir, "tokens.json");
createFileOAuthTokenStore(file).save(TOK);
expect(vi.mocked(renameSync)).toHaveBeenCalled();
const [from, to] = vi.mocked(renameSync).mock.calls[0] as [string, string];
expect(to).toBe(file); // renamed ONTO the real path
expect(String(from)).not.toBe(file); // ...from a distinct temp file
expect(join(String(from), "..")).toBe(join(file, "..")); // ...in the SAME dir
});
it("persists the token file with 0600 permissions (POSIX)", () => {
if (process.platform === "win32") return; // mode bits aren't meaningful on Windows
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
createFileOAuthTokenStore(file).save(TOK);
expect(statSync(file).mode & 0o777).toBe(0o600);
});
it("does NOT truncate/corrupt a pre-existing valid token file when the write fails mid-way", () => {
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
const store = createFileOAuthTokenStore(file);
// A valid, previously-persisted token file (the live refresh token on disk).
store.save(TOK);
const before = readFileSync(file, "utf-8");
// Simulate a crash / ENOSPC at the atomic-replace step while persisting a
// ROTATED refresh token.
const rotated: OAuthTokens = { ...TOK, accessToken: "a2", refreshToken: "r2" };
vi.mocked(renameSync).mockImplementationOnce(() => {
throw new Error("rename boom");
});
expect(() => store.save(rotated)).toThrow(/rename boom/);
// The original file is untouched: present, byte-identical, still parseable.
expect(readFileSync(file, "utf-8")).toBe(before);
expect(store.load()).toEqual(TOK);
// ...and the failed write left no temp file lying around.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
});
+298
View File
@@ -0,0 +1,298 @@
import axios, { type AxiosInstance } from "axios";
import { createHash, randomBytes } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
/**
* Player-control scopes requested for the USER token: streaming (drives
* librespot as a Connect device) + read/modify playback + currently-playing +
* private-playlist reads.
*/
export const SPOTIFY_CONTROL_SCOPES =
"streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private";
const ACCOUNTS_BASE = "https://accounts.spotify.com";
// Hand a token back only if it survives ~30s, matching webapi.ts's skew.
const EXPIRY_SKEW_MS = 30_000;
// RFC 7636 §4.1 unreserved set: [A-Za-z0-9-._~].
const PKCE_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" };
export interface OAuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope: string;
}
export interface OAuthTokenStore {
load(): OAuthTokens | null;
save(t: OAuthTokens): void;
clear(): void;
}
export interface SpotifyOAuthOptions {
/**
* Correction C3.2: the caller's OWN Spotify Developer app client_id. There is
* no librespot-public-client fallback — an empty clientId disables OAuth.
*/
clientId?: string;
/**
* Loopback redirect registered on the caller's Spotify app, supplied by the
* bot's web layer (e.g. its `/api/spotify/callback`). Must exactly match the
* value used at both the authorize and token steps.
*/
redirectUri?: string;
store: OAuthTokenStore;
deps?: { http?: AxiosInstance; now?: () => number };
}
/** 64 random chars from the PKCE unreserved set (43-128 allowed by the spec). */
export function generateCodeVerifier(): string {
const bytes = randomBytes(64);
let out = "";
for (let i = 0; i < 64; i++) out += PKCE_CHARS[bytes[i] % PKCE_CHARS.length];
return out;
}
/** base64url(SHA256(verifier)) with no padding — the S256 code challenge. */
export function codeChallengeS256(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
/** Persist OAuth tokens as a 0600 JSON file (used by the controller). */
export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore {
return {
load() {
try {
if (!existsSync(filePath)) return null;
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
return parsed?.refreshToken ? (parsed as OAuthTokens) : null;
} catch {
return null; // missing/corrupt -> treat as unauthorized
}
},
save(t: OAuthTokens) {
mkdirSync(dirname(filePath), { recursive: true });
// 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 the token file truncated — a reader always sees either the previous
// file or the fully-written new one, never a partial. This matters because
// refresh() persists a ROTATED refresh token here: Spotify invalidates the
// OLD one the instant it responds, so a torn write would silently
// de-authenticate the operator (next load() JSON.parse-fails -> null ->
// full PKCE re-login). Mirrors config.ts saveConfig. The temp lives in the
// same dir so the rename stays on one filesystem; pid + timestamp keep
// concurrent writers from colliding on the temp name. 0600 is preserved.
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tmp, JSON.stringify(t, null, 2), { mode: 0o600 });
renameSync(tmp, filePath);
} catch (err) {
// Never leave a partial temp file behind on failure.
try {
rmSync(tmp, { force: true });
} catch {
/* best-effort cleanup */
}
throw err;
}
},
clear() {
try {
rmSync(filePath, { force: true });
} catch {
/* already gone */
}
},
};
}
/**
* Authorization Code + PKCE flow for the USER player-control token. Public
* client (no secret).
*
* Correction C3.2: this REQUIRES the operator's own registered Spotify app —
* there is NO reuse of librespot's first-party keymaster client / fixed
* :5588 redirect. Without a clientId, `isAuthorized()` is false and
* `buildAuthorizeUrl()` throws.
*
* Refresh rotates the refresh token, so the newest is always persisted;
* invalid_grant clears the store (re-login required). Access/refresh tokens
* are never logged.
*/
export class SpotifyOAuth {
private clientId: string;
private redirectUri: string;
private store: OAuthTokenStore;
private http: AxiosInstance;
// Injectable clock (tests drive a mutable now); defaults to Date.now.
private now: () => number;
// Pending PKCE verifiers keyed by state, awaiting the loopback redirect back.
private pendingVerifiers = new Map<
string,
{ verifier: string; expiresAt: number }
>();
// A verifier is abandoned if the redirect never returns; drop it after TTL and
// cap the map so parallel logins can't grow it without bound.
private static readonly VERIFIER_TTL_MS = 10 * 60 * 1000;
private static readonly VERIFIER_MAX = 32;
// Collapse concurrent refreshes into one POST (rotation invalidates the token
// a second in-flight refresh would send); cleared in .finally().
private refreshInFlight: Promise<string | null> | null = null;
constructor(o: SpotifyOAuthOptions) {
this.clientId = o.clientId ?? "";
this.redirectUri = o.redirectUri ?? "";
this.store = o.store;
this.http =
o.deps?.http ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
this.now = o.deps?.now ?? (() => Date.now());
}
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);
}
}
/** Update the operator's app credentials at runtime (from Settings save) so
* a UI-entered Client ID takes effect without a process restart. Empty
* clientId disables OAuth (isAuthorized()/buildAuthorizeUrl() gate on it). */
configure(clientId?: string, redirectUri?: string): void {
this.clientId = clientId?.trim() || "";
this.redirectUri = redirectUri || "";
}
getClientId(): string {
return this.clientId;
}
getRedirectUri(): string {
return this.redirectUri;
}
isAuthorized(): boolean {
// C3.2: no client_id means we could never refresh, so treat as unauthorized.
return !!this.clientId && !!this.store.load()?.refreshToken;
}
buildAuthorizeUrl(): { url: string; state: string } {
if (!this.clientId) {
// C3.2: cannot start OAuth against nobody's app.
throw new Error("Set your Spotify Client ID in settings first");
}
const state = randomBytes(16).toString("hex");
const verifier = generateCodeVerifier();
this.evictStaleVerifiers();
this.pendingVerifiers.set(state, {
verifier,
expiresAt: this.now() + SpotifyOAuth.VERIFIER_TTL_MS,
});
const params = new URLSearchParams({
client_id: this.clientId,
response_type: "code",
redirect_uri: this.redirectUri,
code_challenge: codeChallengeS256(verifier),
code_challenge_method: "S256",
scope: SPOTIFY_CONTROL_SCOPES,
state,
});
return { url: `${ACCOUNTS_BASE}/authorize?${params.toString()}`, state };
}
async handleCallback(code: string, state: string): Promise<boolean> {
const entry = this.pendingVerifiers.get(state);
if (!entry || entry.expiresAt < this.now()) {
// unknown/expired state -> CSRF guard (drop any stale entry too)
this.pendingVerifiers.delete(state);
return false;
}
const verifier = entry.verifier;
// C3.7: drop the state->verifier entry on EVERY terminal path (success,
// rejected token exchange, or throw) so a failed login can't leak/replay it.
try {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: verifier,
});
const { data } = await this.http.post("/api/token", body.toString(), {
headers: FORM_HEADERS,
});
if (!data?.access_token || !data?.refresh_token) return false;
this.store.save(this.toTokens(data, data.refresh_token, data.scope));
return true;
} catch {
return false;
} finally {
this.pendingVerifiers.delete(state);
}
}
async getAccessToken(): Promise<string | null> {
if (!this.clientId) return null; // C3.2: no app => nothing to mint against
const tokens = this.store.load();
if (!tokens?.refreshToken) return null; // unauthorized
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;
}
private async refresh(current: OAuthTokens): Promise<string | null> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: current.refreshToken,
client_id: this.clientId,
});
try {
const { data } = await this.http.post("/api/token", body.toString(), {
headers: FORM_HEADERS,
});
if (!data?.access_token) return null;
// PKCE rotates the refresh token; fall back to the current one if omitted.
const rotated = data.refresh_token || current.refreshToken;
const saved = this.toTokens(data, rotated, data.scope ?? current.scope);
this.store.save(saved);
return saved.accessToken;
} catch (err: any) {
// invalid_grant => refresh token revoked/expired: discard, force re-login.
if (err?.response?.data?.error === "invalid_grant") this.store.clear();
return null;
}
}
private toTokens(data: any, refreshToken: string, scope: string): OAuthTokens {
return {
accessToken: data.access_token,
refreshToken,
expiresAt: this.now() + (data.expires_in ?? 3600) * 1000 - EXPIRY_SKEW_MS,
scope: scope ?? SPOTIFY_CONTROL_SCOPES,
};
}
}
+577
View File
@@ -0,0 +1,577 @@
import { describe, it, expect, vi } from "vitest";
import {
mapSpotifyTrack,
mapSpotifyTracks,
mapSpotifyAlbum,
mapSpotifyPlaylist,
isSpotifyUri,
SpotifyWebApi,
} from "./webapi.js";
describe("mapSpotifyTrack", () => {
// Shape trimmed from GET /v1/search?type=track.
const raw = {
id: "4iV5W9uYEdYUVa79Axb7Rh",
name: "Bohemian Rhapsody",
artists: [{ name: "Queen" }],
album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] },
duration_ms: 354320,
};
it("maps a track to a Song with platform 'spotify' and seconds duration", () => {
const s = mapSpotifyTrack(raw);
expect(s.platform).toBe("spotify");
expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh");
expect(s.name).toBe("Bohemian Rhapsody");
expect(s.artist).toBe("Queen");
expect(s.album).toBe("A Night at the Opera");
expect(s.duration).toBe(354); // 354320ms → 354s
expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg");
});
it("joins multiple artists with ', '", () => {
const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] });
expect(s.artist).toBe("A, B");
});
it("tolerates missing fields", () => {
const s = mapSpotifyTrack({});
expect(s.id).toBe("");
expect(s.name).toBe("Unknown");
expect(s.artist).toBe("");
expect(s.duration).toBe(0);
expect(s.coverUrl).toBe("");
expect(s.platform).toBe("spotify");
});
it("mapSpotifyTracks returns [] for non-array input", () => {
expect(mapSpotifyTracks(undefined as any)).toEqual([]);
});
});
describe("mapSpotifyAlbum", () => {
it("maps an album with total_tracks → songCount", () => {
const a = mapSpotifyAlbum({
id: "1abc",
name: "A Night at the Opera",
artists: [{ name: "Queen" }],
images: [{ url: "https://i.scdn.co/a.jpg" }],
total_tracks: 12,
});
expect(a).toEqual({
id: "1abc",
name: "A Night at the Opera",
artist: "Queen",
coverUrl: "https://i.scdn.co/a.jpg",
songCount: 12,
platform: "spotify",
});
});
});
describe("mapSpotifyPlaylist", () => {
it("maps a playlist with tracks.total → songCount", () => {
const p = mapSpotifyPlaylist({
id: "37i9",
name: "Today's Top Hits",
images: [{ url: "https://i.scdn.co/p.jpg" }],
tracks: { total: 50 },
});
expect(p).toEqual({
id: "37i9",
name: "Today's Top Hits",
coverUrl: "https://i.scdn.co/p.jpg",
songCount: 50,
platform: "spotify",
});
});
});
describe("isSpotifyUri", () => {
it("recognizes the sentinel URI", () => {
expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true);
expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false);
expect(isSpotifyUri("")).toBe(false);
});
});
describe("SpotifyWebApi rate-limit handling", () => {
it("retries once on 429 (honoring Retry-After) then returns data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success
expect(out.songs[0].id).toBe("t1");
});
// I5: the retry is BOUNDED — get() passes `false` on the recursive call so a
// second 429 is NOT retried. Without that bound arg this recurses forever;
// this test must FAIL (time out) if the `false` in `this.get(path, params, false)`
// is removed. retry-after "0" keeps the single permitted wait instant.
it("gives up after exactly ONE 429 retry when every call 429s (bounded, no infinite loop)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockRejectedValue({
response: { status: 429, headers: { "retry-after": "0" } },
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
// Exactly two attempts: the original + one bounded retry, then it stops.
expect(http.get).toHaveBeenCalledTimes(2);
// Giving up returns null from get() → search yields an empty (non-throwing) result.
expect(out).toEqual({ songs: [], playlists: [], albums: [] });
});
// I5: the advised wait is capped by Math.min(retryAfter, 10). A large Retry-After
// (999s) must still wait only 10s, proving the cap. Fake timers keep it instant
// while letting us assert the retry fires at 10s, not before.
it("caps the Retry-After wait at 10s (Math.min(retryAfter, 10))", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "999" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
// Let the token fetch + first (429) call settle and schedule the wait.
await vi.advanceTimersByTimeAsync(9_000); // 9s < cap → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000); // now 10s total → cap reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Corner-case: a non-numeric Retry-After (HTTP-date) must NOT coerce to NaN and
// fire the retry at 0ms. `Number("Wed, 21 Oct 2025 07:28:00 GMT")` is NaN, so the
// pre-fix `Math.min(NaN,10)*1000` schedules an immediate (or never-firing) retry
// that ignores the advised backoff. Post-fix falls back to a finite 1s wait: the
// retry stays scheduled at exactly the fallback, still bounded to ONE retry.
it("falls back to a finite 1s wait when Retry-After is an HTTP-date (not NaN/immediate)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({
response: {
status: 429,
headers: { "retry-after": "Wed, 21 Oct 2025 07:28:00 GMT" },
},
});
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
// Let the token fetch + first (429) call settle and schedule the wait.
await vi.advanceTimersByTimeAsync(999); // < 1s fallback → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // now 1s total → finite fallback reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2); // exactly one bounded retry
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Corner-case sibling: an empty Retry-After coerces to 0 (`Number("")` === 0), so
// pre-fix `Math.min(0,10)*1000` fires the retry immediately at 0ms. Post-fix guards
// `raw > 0`, so it falls back to the same finite 1s wait rather than firing at 0.
it("falls back to a finite 1s wait when Retry-After is empty (not 0/immediate)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
await vi.advanceTimersByTimeAsync(999); // < 1s fallback → retry must NOT have fired at 0ms
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // 1s total → fallback reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Regression guard: a NORMAL numeric Retry-After ("3") is finite/positive, so the
// guard is transparent — the advised ~3s wait (below the 10s cap) is honored intact.
it("still honors a normal numeric Retry-After (~3s, below the 10s cap)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "3" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
await vi.advanceTimersByTimeAsync(2_999); // < 3s advised → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // 3s total → advised wait reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
it("returns empty results when unconfigured (no creds → no token)", async () => {
const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" }));
expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] });
});
});
// m4: the catalog fetch mappers (getTrack / getAlbumTracks / getPlaylistTracks)
// were untested. They shape raw Spotify payloads into Songs, inject album cover
// context, and drop malformed playlist rows.
describe("SpotifyWebApi catalog mappers", () => {
/** Builds an api whose single http.get resolves the supplied payload. */
function makeApi(payload: unknown) {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = { get: vi.fn().mockResolvedValue({ data: payload }) } as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
return { api, http };
}
describe("getTrack", () => {
it("maps a single track payload to a Song", async () => {
const { api } = makeApi({
id: "trk1",
name: "Song One",
artists: [{ name: "Alice" }, { name: "Bob" }],
album: { name: "Album X", images: [{ url: "https://i.scdn.co/t.jpg" }] },
duration_ms: 210000,
});
const s = await api.getTrack("trk1");
expect(s).toEqual({
id: "trk1",
name: "Song One",
artist: "Alice, Bob",
album: "Album X",
duration: 210,
coverUrl: "https://i.scdn.co/t.jpg",
platform: "spotify",
});
});
it("returns null when the track fetch yields no data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = { get: vi.fn().mockResolvedValue({ data: null }) } as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
expect(await api.getTrack("nope")).toBeNull();
});
});
describe("getAlbumTracks", () => {
it("injects the album name + cover into every returned track", async () => {
// Album-track objects omit their own album block; the album cover/name must
// be back-filled from the album payload.
const { api } = makeApi({
name: "A Night at the Opera",
images: [{ url: "https://i.scdn.co/album.jpg" }],
tracks: {
items: [
{ id: "a1", name: "Death on Two Legs", artists: [{ name: "Queen" }], duration_ms: 223000 },
{ id: "a2", name: "Lazing on a Sunday", artists: [{ name: "Queen" }], duration_ms: 68000 },
],
},
});
const out = await api.getAlbumTracks("alb1");
expect(out).toHaveLength(2);
for (const t of out) {
expect(t.album).toBe("A Night at the Opera");
expect(t.coverUrl).toBe("https://i.scdn.co/album.jpg");
expect(t.platform).toBe("spotify");
}
expect(out[0].id).toBe("a1");
expect(out[0].name).toBe("Death on Two Legs");
expect(out[1].id).toBe("a2");
});
it("drops null track entries and returns [] when tracks.items is absent", async () => {
const { api: withNull } = makeApi({
name: "Alb",
images: [{ url: "https://i.scdn.co/c.jpg" }],
tracks: { items: [null, { id: "ok", name: "Keep", artists: [], duration_ms: 1000 }] },
});
const out = await withNull.getAlbumTracks("alb1");
expect(out).toHaveLength(1);
expect(out[0].id).toBe("ok");
expect(out[0].coverUrl).toBe("https://i.scdn.co/c.jpg");
const { api: noItems } = makeApi({ name: "Alb", images: [], tracks: {} });
expect(await noItems.getAlbumTracks("alb1")).toEqual([]);
});
});
describe("getPlaylistTracks", () => {
it("filters null items, {track:null}, and id-less tracks (never emits an id:'' Song)", async () => {
const { api } = makeApi({
items: [
null,
{ track: null },
{ track: { name: "No Id Here", artists: [], duration_ms: 1000 } }, // track present but no id
{ track: { id: "p1", name: "Real Song", artists: [{ name: "X" }], duration_ms: 1000 } },
],
});
const out = await api.getPlaylistTracks("pl1");
expect(out).toHaveLength(1);
expect(out[0].id).toBe("p1");
expect(out[0].name).toBe("Real Song");
// Guard the specific defect: no malformed empty-id Song leaks through.
expect(out.every((s) => s.id !== "")).toBe(true);
});
it("returns [] when items is absent", async () => {
const { api } = makeApi({});
expect(await api.getPlaylistTracks("pl1")).toEqual([]);
});
});
});
// R2-3: getPlaylistTracks must paginate (follow data.next) rather than silently
// truncating to the first 100 tracks, and must stop at a bounded cap so a
// pathological 10k-track playlist can't fan out into dozens of API calls.
describe("getPlaylistTracks pagination (R2-3)", () => {
const trackItem = (i: number) => ({
track: { id: `p${i}`, name: `Song ${i}`, artists: [{ name: "X" }], duration_ms: 1000 },
});
it("follows data.next across pages, returning all tracks in order with nulls filtered", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const page1 = {
items: Array.from({ length: 100 }, (_, i) => trackItem(i)),
next: "https://api.spotify.com/v1/playlists/pl1/tracks?offset=100&limit=100",
};
// Nulls / {track:null} / id-less rows on page 2 prove cross-page filtering.
const page2 = {
items: [null, { track: null }, { track: { name: "NoId", artists: [], duration_ms: 1 } }, trackItem(100), trackItem(101)],
next: null,
};
const http = {
get: vi.fn().mockResolvedValueOnce({ data: page1 }).mockResolvedValueOnce({ data: page2 }),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getPlaylistTracks("pl1");
expect(http.get).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(102); // 100 (page 1) + 2 real (page 2, three junk rows dropped)
expect(out[0].id).toBe("p0");
expect(out[99].id).toBe("p99");
expect(out[100].id).toBe("p100"); // page 2 appended in order
expect(out[101].id).toBe("p101");
expect(out.every((s) => s.id !== "")).toBe(true);
});
it("stops at the bounded cap when pages never end (call count bounded)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
// Every page is full (100) and always advertises a further next page.
const http = {
get: vi.fn().mockImplementation((_path: string, cfg: any) => {
const offset = Number(cfg?.params?.offset ?? 0);
return Promise.resolve({
data: {
items: Array.from({ length: 100 }, (_, i) => trackItem(offset + i)),
next: `https://api.spotify.com/v1/playlists/pl1/tracks?offset=${offset + 100}`,
},
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getPlaylistTracks("pl1");
expect(out.length).toBeLessThanOrEqual(500); // never exceeds the cap
expect(out).toHaveLength(500); // and reaches it exactly
expect(http.get.mock.calls.length).toBeLessThanOrEqual(5); // 500 cap / 100 per page
});
});
// R2-6: getAlbumTracks must page beyond the 50-track embedded cap via the
// dedicated /albums/{id}/tracks endpoint, still injecting album name + cover.
describe("getAlbumTracks pagination (R2-6)", () => {
const albTrack = (i: number) => ({ id: `a${i}`, name: `T${i}`, artists: [{ name: "Queen" }], duration_ms: 1000 });
it("pages beyond the embedded 50-track cap, injecting album name/cover", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const albumPayload = {
name: "Big Compilation",
images: [{ url: "https://i.scdn.co/big.jpg" }],
tracks: {
items: Array.from({ length: 50 }, (_, i) => albTrack(i)),
next: "https://api.spotify.com/v1/albums/alb1/tracks?offset=50&limit=50",
},
};
const page2 = { items: [albTrack(50), albTrack(51), null, albTrack(52)], next: null };
const http = {
get: vi
.fn()
.mockResolvedValueOnce({ data: albumPayload }) // GET /v1/albums/alb1
.mockResolvedValueOnce({ data: page2 }), // GET /v1/albums/alb1/tracks
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getAlbumTracks("alb1");
expect(http.get).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(53); // 50 + 3 real (one null dropped)
for (const t of out) {
expect(t.album).toBe("Big Compilation");
expect(t.coverUrl).toBe("https://i.scdn.co/big.jpg");
expect(t.platform).toBe("spotify");
}
expect(out[0].id).toBe("a0");
expect(out[49].id).toBe("a49");
expect(out[50].id).toBe("a50"); // page 2 appended in order
expect(out[52].id).toBe("a52");
});
// Robustness: a malformed/proxy response of { items: [], next: <non-null> } never
// grows songs.length nor nulls `next`, so a while-loop bounded ONLY by
// songs.length >= cap spins forever. getAlbumTracks must have a hard offset/page
// bound (mirroring the playlist loop) so it TERMINATES regardless of items/next.
it("terminates on a { items: [], next: <non-null> } response (bounded call count, no hang)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockImplementation((path: string) => {
if (path === "/v1/albums/alb1") {
// Even the embedded first page is malformed: empty items, non-null next.
return Promise.resolve({
data: {
name: "Broken",
images: [{ url: "https://i.scdn.co/broken.jpg" }],
tracks: { items: [], next: "http://x/next" },
},
});
}
// Every /albums/{id}/tracks page keeps advertising a further page forever.
return Promise.resolve({ data: { items: [], next: "http://x/next" } });
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getAlbumTracks("alb1");
// Returns what it has (nothing) rather than hanging.
expect(out).toEqual([]);
// Bounded: 1 album GET + at most MAX_ALBUM_TRACKS/ALBUM_PAGE_SIZE (=10) page fetches.
expect(http.get.mock.calls.length).toBeLessThanOrEqual(12);
});
});
// R2-7: search() must null-filter tracks.items like its album/playlist siblings so
// an unavailable/relinked null track never becomes a bogus id:'' "Unknown" Song.
describe("search track null-filtering (R2-7)", () => {
it("drops null and id-less track entries (never emits an empty-id 'Unknown' song)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockResolvedValue({
data: {
tracks: {
items: [
null, // unavailable/relinked track
{ id: "t1", name: "Real", artists: [{ name: "X" }], duration_ms: 1000 },
{}, // id-less → maps to {id:'', name:'Unknown'}, must also be dropped
],
},
albums: { items: [] },
playlists: { items: [] },
},
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("x");
expect(out.songs).toHaveLength(1);
expect(out.songs[0].id).toBe("t1");
expect(out.songs.some((s) => s.id === "")).toBe(false);
expect(out.songs.some((s) => s.name === "Unknown")).toBe(false);
});
});
+232
View File
@@ -0,0 +1,232 @@
import axios, { type AxiosInstance } from "axios";
import type { Song, Album, Playlist, SearchResult } from "../provider.js";
export interface SpotifyCreds {
clientId: string;
clientSecret: string;
}
const ACCOUNTS_BASE = "https://accounts.spotify.com";
const API_BASE = "https://api.spotify.com";
// Spotify pages collection tracks at 100 (playlists) / 50 (albums). Follow the
// paging links, but bound the fan-out so a pathological 10k-track collection
// can't explode into dozens of API calls; stop once the cap is reached.
const PLAYLIST_PAGE_SIZE = 100;
const ALBUM_PAGE_SIZE = 50;
const MAX_PLAYLIST_TRACKS = 500;
const MAX_ALBUM_TRACKS = 500;
function artistsToString(artists: unknown): string {
return Array.isArray(artists)
? artists.map((a: any) => a?.name).filter(Boolean).join(", ")
: "";
}
/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */
export function mapSpotifyTrack(raw: any): Song {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
album: raw?.album?.name ?? "",
duration: Math.round((raw?.duration_ms ?? 0) / 1000),
coverUrl: raw?.album?.images?.[0]?.url ?? "",
platform: "spotify",
};
}
export function mapSpotifyTracks(raw: any): Song[] {
return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : [];
}
export function mapSpotifyAlbum(raw: any): Album {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.total_tracks ?? 0,
platform: "spotify",
};
}
export function mapSpotifyPlaylist(raw: any): Playlist {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.tracks?.total ?? 0,
platform: "spotify",
};
}
/** True for the getSongUrl sentinel (spotify:track:<id>); real audio lands in Stage 2/3. */
export function isSpotifyUri(url: string): boolean {
return typeof url === "string" && url.startsWith("spotify:");
}
export class SpotifyWebApi {
private getCreds: () => SpotifyCreds;
private http: AxiosInstance;
private auth: AxiosInstance;
private token = "";
private tokenExpiresAt = 0;
constructor(
getCreds: () => SpotifyCreds,
deps?: { http?: AxiosInstance; auth?: AxiosInstance }
) {
this.getCreds = getCreds;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
}
setCreds(_c: SpotifyCreds): void {
// Creds are read live via getCreds(); force a token refresh on next call.
this.token = "";
this.tokenExpiresAt = 0;
}
hasCreds(): boolean {
const c = this.getCreds();
return !!c.clientId && !!c.clientSecret;
}
/** Client-Credentials app token, cached until ~30s before expiry. */
private async getToken(): Promise<string | null> {
if (!this.hasCreds()) return null;
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
const { clientId, clientSecret } = this.getCreds();
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
try {
const { data } = await this.auth.post(
"/api/token",
"grant_type=client_credentials",
{
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
this.token = data?.access_token ?? "";
this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000;
return this.token || null;
} catch {
return null;
}
}
private async get(
path: string,
params?: Record<string, unknown>,
retryOn429 = true
): Promise<any | null> {
const token = await this.getToken();
if (!token) return null;
try {
const { data } = await this.http.get(path, {
params,
headers: { Authorization: `Bearer ${token}` },
});
return data;
} catch (err: any) {
// Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds).
// Retry once after the advised delay before giving up.
if (retryOn429 && err?.response?.status === 429) {
// Retry-After may be a non-numeric HTTP-date or empty string; Number(...)
// then yields NaN/0 and fires the single retry at 0ms, ignoring the advised
// backoff. Guard for a finite positive value (mirrors connect-api.ts).
const raw = Number(err.response?.headers?.["retry-after"]);
const retryAfter = Number.isFinite(raw) && raw > 0 ? raw : 1;
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000));
return this.get(path, params, false);
}
return null;
}
}
async search(query: string, limit = 20): Promise<SearchResult> {
const data = await this.get("/v1/search", {
q: query,
type: "track,album,playlist",
limit,
});
if (!data) return { songs: [], playlists: [], albums: [] };
return {
// Mirror the album/playlist filtering: a null entry (unavailable/relinked
// track) must not become a bogus id:'' "Unknown" Song. Drop empty ids too.
songs: Array.isArray(data?.tracks?.items)
? data.tracks.items
.filter(Boolean)
.map(mapSpotifyTrack)
.filter((s: Song) => s.id !== "")
: [],
albums: Array.isArray(data?.albums?.items)
? data.albums.items.filter(Boolean).map(mapSpotifyAlbum)
: [],
playlists: Array.isArray(data?.playlists?.items)
? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist)
: [],
};
}
async getTrack(id: string): Promise<Song | null> {
const data = await this.get(`/v1/tracks/${id}`);
return data ? mapSpotifyTrack(data) : null;
}
async getAlbumTracks(albumId: string): Promise<Song[]> {
// Album-track objects omit the album block; fetch the album cover once and inject it.
const album = await this.get(`/v1/albums/${albumId}`);
const cover = album?.images?.[0]?.url ?? "";
const albumName = album?.name ?? "";
// Page 1 (up to 50 tracks) is embedded in the album payload; the embedded
// paging object caps at 50, so follow its `next` via the dedicated
// /albums/{id}/tracks endpoint until exhausted or the cap is reached. The
// offset bound is a HARD termination guarantee (mirrors the playlist loop):
// a malformed/proxy response of { items: [], next: <non-null> } never grows
// songs.length nor nulls `next`, so a loop bounded only by songs.length would
// spin forever — the offset cap stops it regardless of items/next.
const songs: Song[] = [];
let page = album?.tracks;
let offset = ALBUM_PAGE_SIZE;
while (page && Array.isArray(page.items)) {
for (const t of page.items.filter(Boolean)) {
songs.push({ ...mapSpotifyTrack(t), album: albumName, coverUrl: cover });
}
if (!page.next || songs.length >= MAX_ALBUM_TRACKS || offset >= MAX_ALBUM_TRACKS) break;
page = await this.get(`/v1/albums/${albumId}/tracks`, {
limit: ALBUM_PAGE_SIZE,
offset,
});
offset += ALBUM_PAGE_SIZE;
}
return songs.slice(0, MAX_ALBUM_TRACKS);
}
async getPlaylistTracks(playlistId: string): Promise<Song[]> {
// A single limit:100 page silently truncates 100+ track playlists. Follow
// `data.next` (advancing offset) until exhausted or the cap is reached, so a
// 10k-track playlist can't fan out into 100 API calls.
const songs: Song[] = [];
for (let offset = 0; offset < MAX_PLAYLIST_TRACKS; offset += PLAYLIST_PAGE_SIZE) {
const data = await this.get(`/v1/playlists/${playlistId}/tracks`, {
limit: PLAYLIST_PAGE_SIZE,
offset,
});
const items = data?.items;
if (!Array.isArray(items)) break;
// Keep the null / {track:null} / id-less filtering across ALL pages.
const mapped = items
.map((it: any) => it?.track)
.filter((t: any) => t && t.id)
.map(mapSpotifyTrack);
songs.push(...mapped);
if (!data.next) break;
}
return songs.slice(0, MAX_PLAYLIST_TRACKS);
}
}
+12 -6
View File
@@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const;
private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> {
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
try {
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
// N results. Best-effort paginate by fetching offset+limit and slicing
// locally. (offset 0 → identical to before.)
const total = offset + limit;
const raw = await runYtDlp([
`ytsearch${limit}:${query}`,
`ytsearch${total}:${query}`,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
]);
const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
const songs: Song[] = lines
.slice(offset, offset + limit)
.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
return { songs, playlists: [], albums: [] };
} catch {
return { songs: [], playlists: [], albums: [] };
+57 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import pino from "pino";
import { TS3Client } from "./client.js";
@@ -83,3 +83,59 @@ describe("TS3Client.getClientServerGroups — live query + parse smoke test", ()
expect(await ts.getClientServerGroups(5)).toEqual([]);
});
});
describe("TS3Client stable identity UID", () => {
it("derives the same client UID after exporting and restoring an identity", () => {
const first = makeClient();
const restored = new TS3Client(
{
host: "localhost",
port: 9987,
queryPort: 10011,
nickname: "RestoredBot",
identity: first.getIdentityExport(),
},
pino({ level: "silent" }),
);
expect(first.getClientUid()).toBeTruthy();
expect(restored.getClientUid()).toBe(first.getClientUid());
});
});
type VisibleUidHarness = {
visibleClientUids: Map<number, string>;
rememberVisibleClientUid(clientId: number, clientUid: string): void;
releaseVisibleClientUid(clientId: number): void;
clearVisibleClientUids(): void;
};
describe("TS3Client visible client UID grace", () => {
afterEach(() => vi.useRealTimers());
it("retains a leaving client's UID for final reordered voice packets", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "managed-bot-uid=");
cache.releaseVisibleClientUid(7);
vi.advanceTimersByTime(999);
expect(cache.visibleClientUids.get(7)).toBe("managed-bot-uid=");
vi.advanceTimersByTime(1);
expect(cache.visibleClientUids.has(7)).toBe(false);
});
it("lets a new clientEnter overwrite a reused id and cancel stale cleanup", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "old-managed-bot-uid=");
cache.releaseVisibleClientUid(7);
cache.rememberVisibleClientUid(7, "new-human-uid=");
vi.advanceTimersByTime(1_000);
expect(cache.visibleClientUids.get(7)).toBe("new-human-uid=");
cache.clearVisibleClientUids();
});
});
+95
View File
@@ -3,6 +3,7 @@ import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
getUidFromPublicKey,
identityFromString,
sendTextMessage,
listChannels,
@@ -15,6 +16,7 @@ import {
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
type VoiceData,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
@@ -23,6 +25,10 @@ import {
type ServerProtocol,
} from "./protocol-detect.js";
import { TS6HttpQuery } from "./http-query.js";
import {
TrackingVoiceEndpointResolver,
type ResolvedVoiceEndpoint,
} from "./voice-endpoint.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
@@ -65,6 +71,20 @@ export interface TS3TextMessage {
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
/** Lightweight voice-packet signal used for activity detection. The encoded
* payload is intentionally not forwarded beyond this protocol wrapper. */
export interface TS3VoiceActivity {
clientId: number;
codec: number;
/** Stable TeamSpeak identity when the sender is present in the client view. */
clientUid?: string;
}
// Command notifications and UDP voice packets can be reordered in flight.
// Retain a leaving client's UID briefly so its final packet is still
// attributable; a new clientEnter for the same id cancels and overwrites it.
const VISIBLE_CLIENT_UID_RELEASE_GRACE_MS = 1_000;
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
@@ -85,12 +105,19 @@ export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
export class TS3Client extends EventEmitter {
private client: TS3FullClient | null = null;
private identity: Identity;
private readonly clientUid: string;
private clientId = 0;
private readonly visibleClientUids = new Map<number, string>();
private readonly visibleClientUidReleaseTimers = new Map<
number,
ReturnType<typeof setTimeout>
>();
private logger: Logger;
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
private readonly voiceEndpointResolver = new TrackingVoiceEndpointResolver();
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
@@ -101,6 +128,7 @@ export class TS3Client extends EventEmitter {
} else {
this.identity = genTS3Identity(8);
}
this.clientUid = getUidFromPublicKey(this.identity.publicKeyBase64());
}
/** The detected (or forced) server protocol after connect(). */
@@ -114,6 +142,8 @@ export class TS3Client extends EventEmitter {
}
async connect(): Promise<void> {
this.voiceEndpointResolver.reset();
this.clearVisibleClientUids();
// Clean up any existing connection before creating a new one
if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting");
@@ -213,6 +243,7 @@ export class TS3Client extends EventEmitter {
// Forward server password to the protocol library so it can be
// included in clientinit for password-protected servers
serverPassword: this.options.serverPassword,
resolver: this.voiceEndpointResolver,
logger: {
debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg),
@@ -222,16 +253,32 @@ export class TS3Client extends EventEmitter {
});
this.client.on("textMessage", (msg: TextMessage) => {
if (msg.invokerID === this.clientId) return;
this.emit("textMessage", toTS3TextMessage(msg));
});
this.client.on("voiceData", (voice: VoiceData) => {
// The library normally suppresses our own packets; retain the explicit
// guard so a future protocol change cannot make a bot duck itself.
if (voice.clientId === this.clientId) return;
const clientUid = this.visibleClientUids.get(voice.clientId);
const activity: TS3VoiceActivity = {
clientId: voice.clientId,
codec: voice.codec,
...(clientUid ? { clientUid } : {}),
};
this.emit("voiceActivity", activity);
});
this.client.on("disconnected", (err) => {
this.logger.warn({ err: err?.message }, "Connection closed");
this.clientId = 0;
this.clearVisibleClientUids();
this.emit("disconnected");
});
this.client.on("clientEnter", (info: ClientInfo) => {
this.rememberVisibleClientUid(info.id, info.uid);
this.logger.debug(
{ nickname: info.nickname, id: info.id },
"Client entered"
@@ -240,6 +287,7 @@ export class TS3Client extends EventEmitter {
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.releaseVisibleClientUid(ev.id);
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
@@ -429,6 +477,52 @@ export class TS3Client extends EventEmitter {
return this.clientId;
}
/** Actual endpoint selected by the SDK's SRV/TSDNS discovery and DNS lookup. */
getResolvedVoiceEndpoint(): ResolvedVoiceEndpoint | null {
return this.voiceEndpointResolver.getEndpoint();
}
/** Stable identity of this managed TeamSpeak client. */
getClientUid(): string {
return this.clientUid;
}
private rememberVisibleClientUid(clientId: number, clientUid: string): void {
const pendingRelease = this.visibleClientUidReleaseTimers.get(clientId);
if (pendingRelease) clearTimeout(pendingRelease);
this.visibleClientUidReleaseTimers.delete(clientId);
if (clientId > 0 && clientUid) {
this.visibleClientUids.set(clientId, clientUid);
} else {
this.visibleClientUids.delete(clientId);
}
}
private releaseVisibleClientUid(clientId: number): void {
const clientUid = this.visibleClientUids.get(clientId);
if (!clientUid) return;
const previous = this.visibleClientUidReleaseTimers.get(clientId);
if (previous) clearTimeout(previous);
const timer = setTimeout(() => {
if (this.visibleClientUids.get(clientId) === clientUid) {
this.visibleClientUids.delete(clientId);
}
this.visibleClientUidReleaseTimers.delete(clientId);
}, VISIBLE_CLIENT_UID_RELEASE_GRACE_MS);
timer.unref?.();
this.visibleClientUidReleaseTimers.set(clientId, timer);
}
private clearVisibleClientUids(): void {
for (const timer of this.visibleClientUidReleaseTimers.values()) {
clearTimeout(timer);
}
this.visibleClientUidReleaseTimers.clear();
this.visibleClientUids.clear();
}
disconnect(): void {
if (this.client && !this.disconnecting) {
this.disconnecting = true;
@@ -441,6 +535,7 @@ export class TS3Client extends EventEmitter {
});
}
this.clientId = 0;
this.clearVisibleClientUids();
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { AddrResolver, ResolvedAddr } from "@honeybbq/teamspeak-client";
import { TrackingVoiceEndpointResolver } from "./voice-endpoint.js";
function result(addr: string): ResolvedAddr {
return { addr, source: "test", expiry: new Date(0) };
}
function delegate(...addresses: string[]): AddrResolver {
return {
resolve: vi.fn(async () => addresses.map(result)),
};
}
describe("TrackingVoiceEndpointResolver", () => {
it("pins a DNS alias to the IPv4 endpoint used by the UDP connection", async () => {
const resolveHost = vi.fn(async () => "203.0.113.20");
const resolver = new TrackingVoiceEndpointResolver(
delegate("voice-alias.example.com:9987"),
resolveHost,
);
const resolved = await resolver.resolve("voice.example.com:9987");
expect(resolveHost).toHaveBeenCalledWith("voice-alias.example.com");
expect(resolved[0]?.addr).toBe("203.0.113.20:9987");
expect(resolver.getEndpoint()).toEqual({ host: "203.0.113.20", port: 9987 });
});
it("preserves the port chosen by SRV/TSDNS discovery", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("srv-target.example.com:12000"),
async () => "198.51.100.8",
);
expect((await resolver.resolve("voice.example.com:9987"))[0]?.addr).toBe(
"198.51.100.8:12000",
);
expect(resolver.getEndpoint()?.port).toBe(12000);
});
it("keeps the SDK target as a safe fallback when A-record lookup fails", async () => {
const original = "voice.example.com:9987";
const resolver = new TrackingVoiceEndpointResolver(
delegate(original),
async () => {
throw new Error("dns unavailable");
},
);
expect((await resolver.resolve(original))[0]?.addr).toBe(original);
expect(resolver.getEndpoint()).toEqual({ host: "voice.example.com", port: 9987 });
});
it("does not mutate secondary SDK candidates", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("first.example.com:9987", "second.example.com:9988"),
async () => "192.0.2.4",
);
const resolved = await resolver.resolve("voice.example.com:9987");
expect(resolved.map((candidate) => candidate.addr)).toEqual([
"192.0.2.4:9987",
"second.example.com:9988",
]);
});
it("clears the observed endpoint before a reconnect", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("voice.example.com:9987"),
async () => "192.0.2.5",
);
await resolver.resolve("voice.example.com:9987");
resolver.reset();
expect(resolver.getEndpoint()).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { Resolver } from "@honeybbq/teamspeak-client/discovery";
import type {
AddrResolver,
ResolvedAddr,
} from "@honeybbq/teamspeak-client";
export interface ResolvedVoiceEndpoint {
host: string;
port: number;
}
type ResolveIpv4 = (host: string) => Promise<string>;
function parseVoiceAddress(address: string): ResolvedVoiceEndpoint | null {
let host: string;
let rawPort: string;
if (address.startsWith("[")) {
const closingBracket = address.indexOf("]");
if (closingBracket < 0 || address[closingBracket + 1] !== ":") return null;
host = address.slice(1, closingBracket);
rawPort = address.slice(closingBracket + 2);
} else {
const separator = address.lastIndexOf(":");
if (separator <= 0) return null;
host = address.slice(0, separator);
rawPort = address.slice(separator + 1);
}
const port = Number(rawPort);
if (
host.length === 0 ||
!Number.isInteger(port) ||
port < 1 ||
port > 65_535
) {
return null;
}
return { host, port };
}
function formatVoiceAddress(endpoint: ResolvedVoiceEndpoint): string {
return endpoint.host.includes(":")
? `[${endpoint.host}]:${endpoint.port}`
: `${endpoint.host}:${endpoint.port}`;
}
async function resolveIpv4(host: string): Promise<string> {
if (isIP(host) === 4) return host;
return (await lookup(host, { family: 4 })).address;
}
/**
* Uses the SDK's normal SRV/TSDNS discovery, then pins its selected hostname
* to the IPv4 address that the UDP connection will use. Besides making the
* connection target observable, this gives all bots a common registry scope
* when one is configured with a DNS alias and another with the underlying IP.
*/
export class TrackingVoiceEndpointResolver implements AddrResolver {
private endpoint: ResolvedVoiceEndpoint | null = null;
constructor(
private readonly delegate: AddrResolver = new Resolver(),
private readonly resolveHost: ResolveIpv4 = resolveIpv4,
) {}
async resolve(input: string, signal?: AbortSignal): Promise<ResolvedAddr[]> {
this.endpoint = null;
const candidates = await this.delegate.resolve(input, signal);
const selected = candidates[0];
if (!selected) return candidates;
const parsed = parseVoiceAddress(selected.addr);
if (!parsed) return candidates;
try {
const pinned = {
host: await this.resolveHost(parsed.host),
port: parsed.port,
};
this.endpoint = pinned;
return [
{ ...selected, addr: formatVoiceAddress(pinned) },
...candidates.slice(1),
];
} catch {
// Preserve the SDK's original target if local A-record resolution fails.
// The connection may still succeed through platform-specific resolution;
// the registry then falls back to the logical host + resolved port.
this.endpoint = parsed;
return candidates;
}
}
reset(): void {
this.endpoint = null;
}
getEndpoint(): ResolvedVoiceEndpoint | null {
return this.endpoint ? { ...this.endpoint } : null;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import type { MusicProvider } from "../../music/provider.js";
import { getDefaultConfig, type JellyfinConfig } from "../../data/config.js";
import { createAuthRouter } from "./auth.js";
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
return { platform } as unknown as MusicProvider;
}
describe("auth router POST /jellyfin/test", () => {
function mount(
stored: Partial<JellyfinConfig>,
user: unknown = { role: "admin" },
) {
const config = getDefaultConfig();
Object.assign(config.jellyfin, stored);
const testConnection = vi.fn().mockResolvedValue({ ok: true, serverName: "JF" });
const jellyfin = { platform: "jellyfin", testConnection } as unknown as MusicProvider;
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = user; next(); });
app.use(
"/api/auth",
createAuthRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }), undefined, undefined, undefined, jellyfin, config,
),
);
return { app, testConnection };
}
it("fills empty credential fields from the stored config (masked password case)", async () => {
const { app, testConnection } = mount({
serverUrl: "https://old.example.com",
username: "bob",
password: "stored-pw",
});
const res = await request(app)
.post("/api/auth/jellyfin/test")
.send({ serverUrl: "https://new.example.com", username: "bob", password: "" });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: "https://new.example.com",
username: "bob",
password: "stored-pw",
}),
);
});
it("passes freshly entered credentials through", async () => {
const { app, testConnection } = mount({});
await request(app).post("/api/auth/jellyfin/test").send({
serverUrl: "https://jf.example.com",
authMode: "apikey",
apiKey: "key123",
userId: "u1",
});
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({ authMode: "apikey", apiKey: "key123", userId: "u1" }),
);
});
it("is 403 for a member lacking platform.auth", async () => {
const { app, testConnection } = mount({}, { role: "member", capabilities: new Set([]) });
const res = await request(app).post("/api/auth/jellyfin/test").send({});
expect(res.status).toBe(403);
expect(testConnection).not.toHaveBeenCalled();
});
});
+58 -1
View File
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
import type { BotConfig, JellyfinConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
@@ -11,7 +12,11 @@ export function createAuthRouter(
qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger,
cookieStore?: CookieStore
cookieStore?: CookieStore,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
jellyfinProvider?: MusicProvider,
config?: BotConfig
): Router {
const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can
@@ -21,6 +26,9 @@ export function createAuthRouter(
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -65,6 +73,7 @@ export function createAuthRouter(
if (status === "confirmed") {
const cookie = provider.getCookie();
const plat = (platform as string) === "bilibili" ? "bilibili" as const
: (platform as string) === "kugou" ? "kugou" as const
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
if (cookie && cookieStore) {
cookieStore.save(plat, cookie);
@@ -79,6 +88,45 @@ export function createAuthRouter(
}
});
// Jellyfin has no QR/cookie flow — the connection is admin-configured. This
// round-trips /System/Info so Settings can verify form values BEFORE saving.
// Empty/missing credential fields fall back to the stored config, so a
// masked (not re-entered) password still tests the live setup.
router.post("/jellyfin/test", requirePermission("platform.auth"), async (req, res) => {
const testable = jellyfinProvider as
| (MusicProvider & {
testConnection?: (
candidate?: JellyfinConfig,
) => Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }>;
})
| undefined;
if (!testable?.testConnection) {
res.status(501).json({ error: "Jellyfin provider not available" });
return;
}
try {
const body = (req.body ?? {}) as Partial<JellyfinConfig>;
const stored = config?.jellyfin;
const str = (v: unknown, fallback: string) =>
typeof v === "string" && v.trim() !== "" ? v.trim() : fallback;
const candidate: JellyfinConfig = {
serverUrl: str(body.serverUrl, stored?.serverUrl ?? ""),
authMode:
body.authMode === "apikey" || body.authMode === "userpass"
? body.authMode
: stored?.authMode ?? "userpass",
username: str(body.username, stored?.username ?? ""),
password: str(body.password, stored?.password ?? ""),
apiKey: str(body.apiKey, stored?.apiKey ?? ""),
userId: str(body.userId, stored?.userId ?? ""),
};
res.json(await testable.testConnection(candidate));
} catch (err) {
logger.error({ err }, "Jellyfin test connection failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone } = req.body;
@@ -134,9 +182,18 @@ export function createAuthRouter(
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
return;
}
// Jellyfin auth is server-configured (Settings → connection card), not
// cookie-based; falling through would clobber the NetEase cookie entry.
if (platform === "jellyfin") {
res
.status(400)
.json({ error: "Jellyfin 通过 Settings 配置连接,不支持手动 Cookie" });
return;
}
const provider = getProvider(platform);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
: platform === "kugou" ? "kugou" as const
: platform === "qq" ? "qq" as const : "netease" as const;
if (cookieStore) {
cookieStore.save(plat, cookie);
+544 -3
View File
@@ -3,7 +3,7 @@ import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase, type BotDatabase } from "../../data/database.js";
@@ -13,21 +13,30 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import {
getDefaultConfig,
type BotConfig,
type JellyfinConfig,
type VoiceDuckingConfig,
} from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
/** Records live settings updates so the tests can assert per-bot propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
voiceDuckingCalls: [] as VoiceDuckingConfig[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
updateVoiceDucking(settings: VoiceDuckingConfig) {
this.voiceDuckingCalls.push({ ...settings });
},
};
}
@@ -84,6 +93,60 @@ describe("bot router /settings", () => {
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("GET /settings includes voiceDucking with safe defaults", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.voiceDucking).toEqual({ enabled: false, volumePercent: 30 });
});
it("POST /settings safely partial-merges, persists and hot-applies voiceDucking", async () => {
const enable = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: true } });
expect(enable.status).toBe(200);
expect(enable.body.voiceDucking).toEqual({ enabled: true, volumePercent: 30 });
const setVolume = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { volumePercent: 42.5 } });
expect(setVolume.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([
{ enabled: true, volumePercent: 30 },
{ enabled: true, volumePercent: 42.5 },
]);
}
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
});
it("POST /settings ignores malformed voiceDucking fields and non-object blocks", async () => {
config.voiceDucking = { enabled: true, volumePercent: 25 };
const invalidFields = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: "yes", volumePercent: 101 } });
expect(invalidFields.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
const arrayBlock = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: [{ enabled: false, volumePercent: 0 }] });
expect(arrayBlock.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([{ enabled: true, volumePercent: 25 }]);
}
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
@@ -196,6 +259,336 @@ describe("bot router /settings", () => {
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
it("GET /settings includes a masked spotify block (hasClientSecret, never a raw secret)", async () => {
config.spotify.enabled = true;
config.spotify.backend = "librespot";
config.spotify.clientId = "cid-1";
config.spotify.deviceName = "MyDevice";
config.spotify.bitrate = 160;
config.spotify.clientSecret = "supersecret";
const withSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(withSecret.status).toBe(200);
expect(withSecret.body.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "cid-1",
deviceName: "MyDevice",
bitrate: 160,
hasClientSecret: true,
});
// The raw secret is never serialized to the client.
expect(withSecret.body.spotify).not.toHaveProperty("clientSecret");
config.spotify.clientSecret = "";
const noSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(noSecret.body.spotify.hasClientSecret).toBe(false);
expect(noSecret.body.spotify).not.toHaveProperty("clientSecret");
});
it("POST /settings updates the spotify block, echoes the masked view, and persists", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } });
expect(res.status).toBe(200);
expect(config.spotify.enabled).toBe(true);
expect(config.spotify.backend).toBe("librespot");
expect(config.spotify.clientId).toBe("cid");
expect(config.spotify.deviceName).toBe("Dev");
expect(config.spotify.bitrate).toBe(160);
expect(res.body.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "cid",
deviceName: "Dev",
bitrate: 160,
hasClientSecret: false,
});
expect(res.body.spotify).not.toHaveProperty("clientSecret");
// saveConfig persisted the block to disk.
expect(existsSync(configPath)).toBe(true);
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.spotify.clientId).toBe("cid");
expect(persisted.spotify.backend).toBe("librespot");
});
it("POST /settings ignores an invalid spotify backend/bitrate (partial-merge, no 400)", async () => {
config.spotify.backend = "auto";
config.spotify.bitrate = 320;
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { backend: "bogus", bitrate: 999 } });
expect(res.status).toBe(200);
expect(config.spotify.backend).toBe("auto");
expect(config.spotify.bitrate).toBe(320);
});
it("POST /settings sets a non-empty clientSecret but a blank one never wipes it", async () => {
const set = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientSecret: "newsecret" } });
expect(set.status).toBe(200);
expect(config.spotify.clientSecret).toBe("newsecret");
expect(set.body.spotify.hasClientSecret).toBe(true);
expect(set.body.spotify).not.toHaveProperty("clientSecret");
const blank = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientSecret: "" } });
expect(blank.status).toBe(200);
expect(config.spotify.clientSecret).toBe("newsecret");
expect(blank.body.spotify.hasClientSecret).toBe(true);
});
it("POST /settings ignores a blank/whitespace deviceName but stores a trimmed non-empty one", async () => {
config.spotify.deviceName = "OldDevice";
// Empty string leaves the prior deviceName untouched.
const empty = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: "" } });
expect(empty.status).toBe(200);
expect(config.spotify.deviceName).toBe("OldDevice");
expect(empty.body.spotify.deviceName).toBe("OldDevice");
// Whitespace-only is likewise ignored (trim().length === 0).
const ws = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: " " } });
expect(ws.status).toBe(200);
expect(config.spotify.deviceName).toBe("OldDevice");
// A non-empty value is stored TRIMMED.
const set = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: " Dev " } });
expect(set.status).toBe(200);
expect(config.spotify.deviceName).toBe("Dev");
expect(set.body.spotify.deviceName).toBe("Dev");
});
it("POST /settings that omits spotify leaves config.spotify untouched (no regression)", async () => {
config.spotify.clientId = "keep-me";
config.spotify.clientSecret = "keep-secret";
const before = { ...config.spotify };
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(config.spotify).toEqual(before);
});
it("GET /settings echoes savedQueuesEnabled + playKeepsQueue (default false)", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.savedQueuesEnabled).toBe(false);
expect(res.body.playKeepsQueue).toBe(false);
});
it("POST /settings persists savedQueuesEnabled and playKeepsQueue", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ savedQueuesEnabled: true, playKeepsQueue: true });
expect(res.status).toBe(200);
expect(res.body.savedQueuesEnabled).toBe(true);
expect(res.body.playKeepsQueue).toBe(true);
expect(config.savedQueuesEnabled).toBe(true);
expect(config.playKeepsQueue).toBe(true);
const get = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(get.body.savedQueuesEnabled).toBe(true);
expect(get.body.playKeepsQueue).toBe(true);
});
it("POST /settings ignores non-boolean savedQueuesEnabled without 400", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ savedQueuesEnabled: "nope" });
expect(res.status).toBe(200);
expect(config.savedQueuesEnabled).toBe(false); // unchanged from default
});
});
// Whole-branch I2: saving a Client ID in Settings must re-configure the single
// live SpotifyOAuth so the operator can Connect without a process restart.
describe("bot router /settings applies spotify creds to the live OAuth (I2)", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let configureCalls: Array<[string | undefined, string | undefined]>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-oauth-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig(); // webPort defaults to 3000
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
// Fake OAuth recording every configure(clientId, redirectUri) call.
configureCalls = [];
const fakeOAuth = {
configure(clientId?: string, redirectUri?: string) {
configureCalls.push([clientId, redirectUri]);
},
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("configures the live OAuth once with the derived callback redirectUri when a Client ID is saved", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid", enabled: true } });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([
["cid", `http://127.0.0.1:${config.webPort}/api/spotify/callback`],
]);
});
it("does NOT touch the OAuth when the request has no spotify block", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([]);
});
it("configures with ('', undefined) when a spotify block clears the Client ID (disables OAuth)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "" } });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([["", undefined]]);
});
});
// R2-4: saving Spotify creds in Settings must also refresh the Web API SEARCH
// provider (spotifyProvider.setCreds), not only the OAuth playback path. Without
// this, a fresh install (enabled defaults false) keeps empty search creds until a
// full process restart even after an admin enters Client ID + Secret.
describe("bot router /settings refreshes the Web API search provider creds (R2-4)", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let setCredsCalls: Array<[string, string]>;
let configureCalls: Array<[string | undefined, string | undefined]>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-provider-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
// Fake search provider recording every setCreds(clientId, clientSecret) call.
setCredsCalls = [];
const fakeProvider = {
setCreds(clientId: string, clientSecret: string) {
setCredsCalls.push([clientId, clientSecret]);
},
};
// Fake OAuth so we can assert the playback path is still wired alongside.
configureCalls = [];
const fakeOAuth = {
configure(clientId?: string, redirectUri?: string) {
configureCalls.push([clientId, redirectUri]);
},
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth, fakeProvider),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("calls setCreds once with (clientId, clientSecret) when a spotify block is saved (and OAuth is still configured)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid", clientSecret: "sec", enabled: true } });
expect(res.status).toBe(200);
expect(setCredsCalls).toEqual([["cid", "sec"]]);
// The OAuth playback path is still wired on the same save.
expect(configureCalls.length).toBe(1);
});
it("does NOT call setCreds when the request has no spotify block", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(setCredsCalls).toEqual([]);
});
it("calls setCreds with the PRESERVED stored secret when the spotify block omits/blanks clientSecret", async () => {
config.spotify.clientId = "cid0";
config.spotify.clientSecret = "stored-secret";
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid0", clientSecret: "", enabled: true } });
expect(res.status).toBe(200);
// Post-merge values: masked/blank secret must keep the stored one, never "".
expect(setCredsCalls).toEqual([["cid0", "stored-secret"]]);
});
});
describe("bot router /settings guest-mode gating + persistence", () => {
@@ -251,4 +644,152 @@ describe("bot router /settings guest-mode gating + persistence", () => {
expect(res.body.guestMode.permissions.playNext).toBe(true);
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
});
it("POST /settings spotify write is 403 for a member lacking bot.manage", async () => {
const memberApp = mountBot(() => ({ role: "member", capabilities: new Set([]) }));
const res = await request(memberApp)
.post("/api/bot/settings")
.send({ spotify: { enabled: true, clientId: "cid" } });
expect(res.status).toBe(403);
// Gate rejected before any mutation.
expect(config.spotify.enabled).toBe(false);
expect(config.spotify.clientId).toBe("");
});
});
describe("bot router /settings jellyfin block + enabledProviders", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let configureCalls: JellyfinConfig[];
beforeEach(() => {
botDb = createDatabase(":memory:");
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-jf-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
configureCalls = [];
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
function mountBot(): express.Express {
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = { role: "admin" }; next(); });
app.use(
"/api/bot",
createBotRouter(
fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore,
undefined, undefined, undefined,
{ configure: (cfg: JellyfinConfig) => configureCalls.push({ ...cfg }) },
),
);
return app;
}
it("GET /settings exposes a masked jellyfin block (no password/apiKey echo)", async () => {
config.jellyfin.serverUrl = "https://jf.example.com";
config.jellyfin.password = "secret";
const res = await request(mountBot()).get("/api/bot/settings");
expect(res.status).toBe(200);
expect(res.body.jellyfin).toEqual({
serverUrl: "https://jf.example.com",
authMode: "userpass",
username: "",
userId: "",
hasPassword: true,
hasApiKey: false,
});
// Default: online sources on, jellyfin opt-in (not listed).
expect(res.body.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
});
it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
config.jellyfin.password = "stored-pw";
const app = mountBot();
const res = await request(app).post("/api/bot/settings").send({
jellyfin: { serverUrl: "https://jf.example.com///", username: "bob", password: "" },
});
expect(res.status).toBe(200);
// Trailing slashes normalized; blank password kept the stored one.
expect(config.jellyfin.serverUrl).toBe("https://jf.example.com");
expect(config.jellyfin.username).toBe("bob");
expect(config.jellyfin.password).toBe("stored-pw");
expect(res.body.jellyfin.hasPassword).toBe(true);
// Live provider re-configured with the post-merge values.
expect(configureCalls).toHaveLength(1);
expect(configureCalls[0].serverUrl).toBe("https://jf.example.com");
expect(configureCalls[0].password).toBe("stored-pw");
// Persisted to disk.
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.jellyfin.username).toBe("bob");
});
it("POST /settings filters enabledProviders to known providers", async () => {
const res = await request(mountBot()).post("/api/bot/settings").send({
enabledProviders: ["jellyfin", "netease", "bogus", 42],
});
expect(res.status).toBe(200);
expect(res.body.enabledProviders).toEqual(["jellyfin", "netease"]);
expect(config.enabledProviders).toEqual(["jellyfin", "netease"]);
// No jellyfin block in the request → no reconfigure call.
expect(configureCalls).toHaveLength(0);
});
// --- #126: operator-chosen default source ---
it("GET /settings exposes defaultPlatform (null by default)", async () => {
const res = await request(mountBot()).get("/api/bot/settings");
expect(res.status).toBe(200);
expect(res.body.defaultPlatform).toBeNull();
});
it("POST /settings sets an enabled defaultPlatform and persists it", async () => {
const res = await request(mountBot()).post("/api/bot/settings").send({
defaultPlatform: "bilibili",
});
expect(res.status).toBe(200);
expect(res.body.defaultPlatform).toBe("bilibili");
expect(config.defaultPlatform).toBe("bilibili");
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.defaultPlatform).toBe("bilibili");
});
it("POST /settings ignores an unknown or disabled defaultPlatform", async () => {
const app = mountBot();
// jellyfin is opt-in and not enabled in the default config → rejected.
await request(app).post("/api/bot/settings").send({ defaultPlatform: "jellyfin" });
expect(config.defaultPlatform).toBeNull();
// Unknown value → rejected.
await request(app).post("/api/bot/settings").send({ defaultPlatform: "bogus" });
expect(config.defaultPlatform).toBeNull();
});
it("POST /settings clears defaultPlatform with null", async () => {
const app = mountBot();
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
expect(config.defaultPlatform).toBe("qq");
const res = await request(app).post("/api/bot/settings").send({ defaultPlatform: null });
expect(res.body.defaultPlatform).toBeNull();
expect(config.defaultPlatform).toBeNull();
});
it("POST /settings drops a default whose source gets disabled in the same request", async () => {
const app = mountBot();
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
expect(config.defaultPlatform).toBe("qq");
// Disabling qq via enabledProviders clears the now-invalid default.
const res = await request(app).post("/api/bot/settings").send({
enabledProviders: ["netease", "bilibili"],
});
expect(res.body.defaultPlatform).toBeNull();
expect(config.defaultPlatform).toBeNull();
});
});
+186 -3
View File
@@ -1,7 +1,7 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { BotConfig, GuestModeConfig, SpotifyConfig, JellyfinConfig, GateableProvider } from "../../data/config.js";
import { saveConfig, GATEABLE_PROVIDERS } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js";
@@ -17,9 +17,44 @@ export function createBotRouter(
botDb: BotDatabase,
avatarStore: AvatarStore,
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
// I2: the single process-wide OAuth, so a UI-entered Client ID reaches the
// live instance on save (no restart). Structural type = SpotifyOAuth.configure.
spotifyOAuth?: { configure(clientId?: string, redirectUri?: string): void },
// R2-4: the process-wide Web API SEARCH provider. Boot only wires creds when
// spotify.enabled is already true, so a fresh install that enables Spotify via
// Settings must push creds here too, or search stays empty until a restart.
// Structural type = SpotifyProvider.setCreds.
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
// The live JellyfinProvider, so a Settings save re-points the connection
// without a restart (configure() drops the cached token only when
// credential-relevant fields actually changed). Structural type =
// JellyfinProvider.configure.
jellyfinProvider?: { configure(cfg: JellyfinConfig): void },
): Router {
const router = Router();
// Masked spotify view shared by the GET response and the POST echo. The raw
// clientSecret is write-only and NEVER serialized — only whether one is stored.
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,
});
// Masked jellyfin view: password and apiKey are write-only — only their
// presence is reported (mirrors maskedSpotify's clientSecret handling).
const maskedJellyfin = () => ({
serverUrl: config.jellyfin.serverUrl,
authMode: config.jellyfin.authMode,
username: config.jellyfin.username,
userId: config.jellyfin.userId,
hasPassword: config.jellyfin.password.length > 0,
hasApiKey: config.jellyfin.apiKey.length > 0,
});
router.get("/", (req, res) => {
const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!;
@@ -36,16 +71,30 @@ export function createBotRouter(
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
});
});
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
const {
idleTimeoutMinutes,
autoPauseOnEmpty,
localAudioEnabled,
voiceDucking,
guestMode,
adminGroups,
} = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -60,6 +109,37 @@ export function createBotRouter(
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
// Voice ducking is a partial settings block. Merge only known, strictly
// valid fields so malformed JSON cannot replace the object or inject NaN /
// out-of-range gain values into the live audio path.
const hasVoiceDucking =
voiceDucking !== null &&
typeof voiceDucking === "object" &&
!Array.isArray(voiceDucking);
if (hasVoiceDucking) {
if (typeof voiceDucking.enabled === "boolean") {
config.voiceDucking.enabled = voiceDucking.enabled;
}
if (
typeof voiceDucking.volumePercent === "number" &&
Number.isFinite(voiceDucking.volumePercent) &&
voiceDucking.volumePercent >= 0 &&
voiceDucking.volumePercent <= 100
) {
config.voiceDucking.volumePercent = voiceDucking.volumePercent;
}
}
// Saved-queues + play-keeps-queue toggles (default off). Both read live from
// config by BotInstance / the saved-queues router, so no per-bot push needed;
// only a literal boolean mutates the stored value (junk is ignored).
if (typeof req.body.savedQueuesEnabled === "boolean") {
config.savedQueuesEnabled = req.body.savedQueuesEnabled;
}
if (typeof req.body.playKeepsQueue === "boolean") {
config.playKeepsQueue = req.body.playKeepsQueue;
}
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) {
const gm = config.guestMode;
@@ -85,8 +165,103 @@ export function createBotRouter(
);
}
// Partial-merge the spotify block (mirrors config.ts validation). Invalid
// sub-fields are ignored rather than 400-ing the whole request. Omitting
// `spotify` entirely leaves config.spotify untouched.
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;
}
}
// Partial-merge the jellyfin block (same contract as spotify): invalid
// sub-fields are ignored; password/apiKey are write-only and only stored
// when non-empty so a blank (masked) field never wipes them.
const jf = req.body?.jellyfin;
if (jf && typeof jf === "object") {
const t = config.jellyfin;
if (typeof jf.serverUrl === "string") {
t.serverUrl = jf.serverUrl.trim().replace(/\/+$/, "");
}
if (jf.authMode === "userpass" || jf.authMode === "apikey") t.authMode = jf.authMode;
if (typeof jf.username === "string") t.username = jf.username;
if (typeof jf.password === "string" && jf.password.length > 0) t.password = jf.password;
if (typeof jf.apiKey === "string" && jf.apiKey.length > 0) t.apiKey = jf.apiKey;
if (typeof jf.userId === "string") t.userId = jf.userId;
}
// enabledProviders: full replace, known providers only (mirrors loadConfig).
// An empty array is a valid "all gateable sources off". NOTE: the NetEase/QQ
// sidecar API servers are only started at boot, so newly enabling those two
// still needs a restart; jellyfin and the rest take effect immediately.
const ep = req.body?.enabledProviders;
if (Array.isArray(ep)) {
config.enabledProviders = ep.filter((p: unknown): p is GateableProvider =>
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
);
}
// defaultPlatform (issue #126): the operator-chosen default source for
// platform-less commands/REST/WebUI calls. Reconciled AFTER enabledProviders
// so both are validated against the same (possibly updated) enabled list:
// 1) Drop a stored default that the new enabledProviders no longer allows,
// keeping the persisted config consistent with loadConfig's invariant.
// 2) Apply an explicit change — `null`/`""` clears it (back to priority
// order); a known+enabled provider sets it; anything else is ignored.
if (config.defaultPlatform && !config.enabledProviders.includes(config.defaultPlatform)) {
config.defaultPlatform = null;
}
if ("defaultPlatform" in req.body) {
const dp = req.body.defaultPlatform;
if (dp === null || dp === "") {
config.defaultPlatform = null;
} else if (
typeof dp === "string" &&
(GATEABLE_PROVIDERS as readonly string[]).includes(dp) &&
config.enabledProviders.includes(dp as GateableProvider)
) {
config.defaultPlatform = dp as GateableProvider;
}
}
saveConfig(configPath, config);
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
// provider — a Settings save must work without a restart.
if (jf && typeof jf === "object") {
jellyfinProvider?.configure(config.jellyfin);
}
// I2: only when the spotify block was present, push the (possibly UI-entered)
// Client ID into the live process-wide OAuth so Connect works without a
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
if (sp && typeof sp === "object") {
const redirectUri = config.spotify.clientId
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
: undefined;
spotifyOAuth?.configure(config.spotify.clientId, redirectUri);
// R2-4: also refresh the live Web API search provider so search/getAuthStatus
// work without a restart. Uses the post-merge values so a masked/omitted
// secret keeps the stored one. The secret is never logged.
spotifyProvider?.setCreds(config.spotify.clientId, config.spotify.clientSecret);
}
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
// disabled or narrowed scope takes effect immediately (matches requireAuth's
// "disabling immediately invalidates in-flight guest sessions" invariant).
@@ -98,14 +273,22 @@ export function createBotRouter(
for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
if (hasVoiceDucking) bot.updateVoiceDucking(config.voiceDucking);
}
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
});
});
+505
View File
@@ -0,0 +1,505 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import { createMusicRouter, createLocalUploadBody } from "./music.js";
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
describe("music router provider gating (enabledProviders) + jellyfin endpoints", () => {
function jellyfinFake(): MusicProvider {
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn().mockReturnValue("direct"),
getLatestAlbums: vi
.fn()
.mockResolvedValue([{ id: "a1", name: "Album", platform: "jellyfin" }]),
getFavoriteSongs: vi.fn().mockResolvedValue([]),
} as unknown as MusicProvider;
}
function mount(config: BotConfig) {
const netease = fakeProvider("netease");
const jellyfin = jellyfinFake();
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" }),
undefined,
config,
fakeProvider("kugou"),
fakeProvider("spotify"),
jellyfin,
);
const app = express();
app.use("/api/music", router);
return { app, netease, jellyfin };
}
it("routes a platform-less /search to the default platform (netease)", async () => {
const { app, netease, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
expect(jellyfin.search).not.toHaveBeenCalled();
});
it("rejects a disabled platform with 400 without calling its provider", async () => {
// Default config leaves jellyfin (opt-in) disabled.
const { app, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(400);
expect(jellyfin.search).not.toHaveBeenCalled();
});
it("allows an explicitly enabled jellyfin platform", async () => {
const config = getDefaultConfig();
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
const { app, jellyfin } = mount(config);
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(200);
expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("GET /providers reports enabled sources and the default platform", async () => {
const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
expect(res.body.default).toBe("netease");
expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
expect(res.body.enabled).not.toContain("jellyfin"); // opt-in, off by default
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
});
it("GET /providers reports a configured defaultPlatform override (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "qq"; // operator prefers QQ over the priority order
const { app } = mount(config);
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
expect(res.body.default).toBe("qq");
});
it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "bilibili";
const { app, netease } = mount(config);
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
// Default is now bilibili, so the netease provider must NOT be hit.
expect(netease.search).not.toHaveBeenCalled();
});
/** Default config plus the opt-in jellyfin source enabled. */
function configWithJellyfin() {
const config = getDefaultConfig();
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
return config;
}
it("GET /jellyfin/latest-albums returns provider data", async () => {
const { app, jellyfin } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200);
expect(
(jellyfin as unknown as { getLatestAlbums: ReturnType<typeof vi.fn> }).getLatestAlbums,
).toHaveBeenCalledWith(5);
expect(res.body.albums).toHaveLength(1);
});
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/jellyfin/latest-albums");
expect(res.status).toBe(400);
});
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => {
const { app } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/favorites");
expect(res.status).toBe(401);
});
});
describe("music router POST /quality — persistence (#125)", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let providers: Record<string, MusicProvider>;
/** A provider whose in-memory quality is settable and readable, like the real
* ones. */
function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider {
let q = initial;
return {
platform,
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { q = v; }),
} as unknown as MusicProvider;
}
/** Jellyfin only accepts its own tiers (mirrors the real provider), so a
* broadcast of a foreign value is ignored — proving the snapshot captures each
* provider's ACTUAL post-apply state, not just the request value. */
function jellyfinQualityProvider(): MusicProvider {
let q = "direct";
const tiers = new Set(["direct", "320", "192", "128"]);
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }),
} as unknown as MusicProvider;
}
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "musicquality-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
providers = {
netease: qualityProvider("netease", "exhigh"),
qq: qualityProvider("qq", "exhigh"),
bilibili: qualityProvider("bilibili", "high"),
kugou: qualityProvider("kugou", "128"),
jellyfin: jellyfinQualityProvider(),
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/music",
createMusicRouter(
providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }),
undefined, config, providers.kugou, undefined, providers.jellyfin, configPath,
),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("persists a platform-specific quality change to config.json", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ platform: "netease", quality: "lossless" });
expect(res.status).toBe(200);
expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless");
// in-memory config mutated
expect(config.audioQuality.netease).toBe("lossless");
// written to disk + reload reflects it (survives a restart)
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.audioQuality.netease).toBe("lossless");
expect(loadConfig(configPath).audioQuality.netease).toBe("lossless");
});
it("snapshots each provider's post-apply quality on a broadcast change", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "320" });
expect(res.status).toBe(200);
// Broadcast reached every provider…
expect(providers.netease.setQuality).toHaveBeenCalledWith("320");
expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320");
// …and the snapshot reflects what each one actually accepted. Jellyfin's
// "320" is a valid tier here, so it takes; a foreign value would be ignored.
expect(config.audioQuality).toEqual({
netease: "320",
qq: "320",
bilibili: "320",
kugou: "320",
jellyfin: "320",
});
});
it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "lossless" });
expect(res.status).toBe(200);
// jellyfin rejects the NetEase-style value → stays at its default tier.
expect(config.audioQuality.jellyfin).toBe("direct");
expect(config.audioQuality.netease).toBe("lossless");
});
});
// #149: video containers must survive the transport layer. Before this the
// express.raw type filter only matched audio/*, video/webm and
// application/octet-stream, so a browser-sent video/mp4 body was never parsed
// and the handler answered 400 "raw audio body is required".
describe("music router POST /local/upload — content types and size cap (#149)", () => {
let app: express.Express;
let botDb: BotDatabase;
let cookie: string;
let uploadAudio: ReturnType<typeof vi.fn>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
uploadAudio = vi.fn(async (input: { originalName: string }) => ({
id: "local-1", name: input.originalName, artist: "本地上传", album: "本地音乐",
duration: 1, coverUrl: "", platform: "local",
}));
const local = { platform: "local", search: vi.fn().mockResolvedValue(empty), uploadAudio } as unknown as MusicProvider;
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use("/api/music", createMusicRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }), local, getDefaultConfig(),
));
});
afterEach(() => botDb.close());
const post = (contentType: string, body: Buffer, name = "clip.mp4") =>
request(app)
.post("/api/music/local/upload")
.set("Cookie", cookie)
.set("Content-Type", contentType)
.set("X-Filename", encodeURIComponent(name))
.send(body);
it("accepts the video MIME types browsers actually send", async () => {
// These are what Chrome/Firefox put on a File for .mp4/.mov/.avi/.mkv.
for (const ct of ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/webm"]) {
uploadAudio.mockClear();
const res = await post(ct, Buffer.from("fake video bytes"));
expect(res.status, `content-type ${ct}`).toBe(200);
expect(uploadAudio).toHaveBeenCalledOnce();
expect(res.body.song.platform).toBe("local");
}
});
it("still accepts audio and octet-stream bodies", async () => {
for (const ct of ["audio/mpeg", "audio/flac", "application/octet-stream"]) {
uploadAudio.mockClear();
const res = await post(ct, Buffer.from("fake audio"), "tune.mp3");
expect(res.status, `content-type ${ct}`).toBe(200);
expect(uploadAudio).toHaveBeenCalledOnce();
}
});
it("passes the decoded filename and the content type through to the provider", async () => {
await post("video/mp4", Buffer.from("bytes"), "我的 视频.mp4");
expect(uploadAudio).toHaveBeenCalledWith(
expect.objectContaining({ originalName: "我的 视频.mp4", mimeType: "video/mp4" }),
);
});
it("surfaces a provider rejection as a 400 with its message", async () => {
uploadAudio.mockRejectedValueOnce(new Error("这个视频里没有音轨,无法播放"));
const res = await post("video/mp4", Buffer.from("bytes"));
expect(res.status).toBe(400);
expect(res.body.error).toBe("这个视频里没有音轨,无法播放");
});
it("requires authentication", async () => {
const res = await request(app)
.post("/api/music/local/upload")
.set("Content-Type", "video/mp4")
.send(Buffer.from("bytes"));
expect(res.status).toBe(401);
});
it("rejects an oversize body as JSON, not an HTML stack trace", async () => {
// Same middleware the route mounts, built with a small limit so the test
// does not have to allocate half a gigabyte to reach the cap.
const tiny = express();
const reached = vi.fn();
tiny.post("/u", createLocalUploadBody("1kb"), (_req, res) => { reached(); res.json({ ok: true }); });
const res = await request(tiny)
.post("/u")
.set("Content-Type", "video/mp4")
.send(Buffer.alloc(4096, 1));
expect(res.status).toBe(413);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body.error).toContain("文件太大");
// The HTML default handler leaked absolute server paths and a stack.
expect(res.text).not.toMatch(/node_modules|<\/pre>|at read/);
expect(reached).not.toHaveBeenCalled();
});
it("lets a body under the cap through the same middleware", async () => {
const tiny = express();
tiny.post("/u", createLocalUploadBody("1kb"), (req, res) => {
res.json({ bytes: (req.body as Buffer).length });
});
const res = await request(tiny)
.post("/u")
.set("Content-Type", "video/mp4")
.send(Buffer.alloc(512, 1));
expect(res.status).toBe(200);
expect(res.body.bytes).toBe(512);
});
it("rejects local uploads when the feature is switched off", async () => {
const off = getDefaultConfig();
off.localAudioEnabled = false;
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const a2 = await users.createUser("admin2", "pw-admin2", "admin");
const c2 = `${SESSION_COOKIE_NAME}=${sessions.createSession(a2.id).token}`;
const app2 = express();
app2.use(cookieParser());
app2.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app2.use("/api/music", createMusicRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }),
{ platform: "local", search: vi.fn(), uploadAudio } as unknown as MusicProvider, off,
));
const res = await request(app2)
.post("/api/music/local/upload")
.set("Cookie", c2)
.set("Content-Type", "video/mp4")
.send(Buffer.from("bytes"));
expect(res.status).toBe(403);
expect(uploadAudio).not.toHaveBeenCalled();
});
});
describe("music router GET /bilibili/parts", () => {
it("returns 400 when bvid is missing", async () => {
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts");
expect(res.status).toBe(400);
expect(res.body.error).toBe("bvid is required");
});
it("returns parts from bilibili provider when available", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue({
bvid: "BV1test",
title: "多P视频测试",
parts: [
{ part: 1, cid: 101, title: "P1", duration: 100 },
{ part: 2, cid: 102, title: "P2", duration: 200 },
],
}),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
expect(res.status).toBe(200);
expect(res.body.bvid).toBe("BV1test");
expect(res.body.parts).toHaveLength(2);
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
});
it("returns 404 when getVideoParts returns null", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue(null),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
expect(res.status).toBe(404);
});
});
+295 -25
View File
@@ -1,19 +1,75 @@
import express, { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import express, { Router, type Response } from "express";
import type { MusicProvider, Song, Album } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import type { BotConfig } from "../../data/config.js";
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
/**
* Body cap for a local upload. express.raw buffers the whole body in memory,
* so this is also the peak RAM one upload can cost — raised from 200mb for
* video (#149), which is far bigger than audio for the same song, but kept
* well short of "any video file at all" for that reason. Only the audio track
* survives to disk.
*/
export const LOCAL_UPLOAD_LIMIT = "500mb";
/**
* Body parser for the local-upload route.
*
* `type` includes "video/*" (#149): the browser sends the File's own MIME
* type, so an .mp4 arrives as video/mp4 and used to be rejected by this
* filter before ever reaching the provider. Only the audio track is kept —
* uploadAudio remuxes it out on the way in.
*
* express.raw hands an oversize body to the default error handler, which
* answers with an HTML page carrying a stack trace and absolute server paths
* (unless NODE_ENV=production, which this project never sets). Video makes
* hitting the cap far more likely than audio did, so that one case is
* translated into the same JSON shape the rest of this route returns. Any
* other body-parser error is passed on untouched.
*
* Exported as a factory so tests can drive the identical path with a small
* limit instead of allocating half a gigabyte.
*/
export function createLocalUploadBody(limit: string): express.RequestHandler {
const raw = express.raw({
type: ["audio/*", "video/*", "application/octet-stream"],
limit,
});
return (req, res, next) => {
raw(req, res, (err?: unknown) => {
if (!err) {
next();
return;
}
const e = err as { type?: string; status?: number };
if (e?.type === "entity.too.large" || e?.status === 413) {
res.status(413).json({ error: `文件太大,单个文件上限 ${limit}` });
return;
}
next(err);
});
};
}
const localUploadBody = createLocalUploadBody(LOCAL_UPLOAD_LIMIT);
export function createMusicRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig
config?: BotConfig,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
jellyfinProvider?: MusicProvider,
// When set (alongside config), a quality change is persisted to config.json so
// it survives a restart (#125). Omitted by unit-test routers → no persistence.
configPath?: string,
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -26,9 +82,29 @@ export function createMusicRouter(
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
/**
* Provider gating for user-supplied platform params. No platform → the
* configured default (see defaultPlatform(); netease in the default config).
* A disabled platform gets a friendly 400 and null back — the handler must
* return immediately. Without a config (unit-test routers), everything
* stays enabled.
*/
function resolveProvider(platform: unknown, res: Response): MusicProvider | null {
const requested = typeof platform === "string" && platform ? platform : undefined;
const target = requested ?? (config ? defaultPlatform(config) : "netease");
if (config && !isProviderEnabled(config, target)) {
res.status(400).json({ error: `音源未启用:${target} (provider disabled)` });
return null;
}
return getProvider(target);
}
router.post(
"/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
@@ -39,10 +115,7 @@ export function createMusicRouter(
}
next();
},
express.raw({
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
localUploadBody,
async (req, res) => {
try {
if (!localProvider) {
@@ -82,7 +155,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => {
try {
const { q, platform, limit } = req.query;
const { q, platform, limit, offset } = req.query;
if (!q) {
res.status(400).json({ error: "q (query) is required" });
return;
@@ -91,10 +164,15 @@ export function createMusicRouter(
res.json({ songs: [], playlists: [], albums: [] });
return;
}
const provider = getProvider(platform as string);
const provider = resolveProvider(platform, res);
if (!provider) return;
// Server-side pagination: offset lets the web load past the first page.
// Clamp to >= 0 so a bad/negative value falls back to the first page.
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
const result = await provider.search(
q as string,
parseInt(limit as string) || 20
parseInt(limit as string) || 20,
parsedOffset
);
res.json(result);
} catch (err) {
@@ -111,24 +189,40 @@ export function createMusicRouter(
return;
}
const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
// Spotify is intentionally EXCLUDED from unified /search/all in Stage 1:
// its tracks are metadata-only (not yet playable) until the librespot audio
// backend lands (Stage 2/3), so surfacing them in the default all-sources
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
// Provider gating (#enabledProviders): disabled sources are skipped, not
// searched. Jellyfin (an opt-in source) leads the merged results when
// enabled — a self-hosted library match is almost always the wanted one.
const enabled = (p: string) => !config || isProviderEnabled(config, p);
const none = { songs: [], albums: [], playlists: [] };
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("qq") ? qqProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("bilibili") ? bilibiliProvider.search(q as string, parsedLimit) : Promise.resolve(none),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve(none),
kugouProvider && enabled("kugou") ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve(none),
]);
const songs = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.songs : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
];
const albums = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.albums : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.playlists : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
@@ -146,7 +240,8 @@ export function createMusicRouter(
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const song = await provider.getSongDetail(req.params.id);
if (!song) {
res.status(404).json({ error: "Song not found" });
@@ -160,7 +255,8 @@ export function createMusicRouter(
router.get("/playlist/:id", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const songs = await provider.getPlaylistSongs(req.params.id);
res.json({ songs });
} catch (err) {
@@ -170,7 +266,8 @@ export function createMusicRouter(
router.get("/recommend/playlists", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const playlists = await provider.getRecommendPlaylists();
res.json({ playlists });
} catch (err) {
@@ -180,7 +277,8 @@ export function createMusicRouter(
router.get("/album/:id", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const songs = await provider.getAlbumSongs(req.params.id);
res.json({ songs });
} catch (err) {
@@ -190,7 +288,8 @@ export function createMusicRouter(
router.get("/lyrics/:id", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const lyrics = await provider.getLyrics(req.params.id);
res.json({ lyrics });
} catch (err) {
@@ -200,7 +299,8 @@ export function createMusicRouter(
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getDailyRecommendSongs) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -215,7 +315,8 @@ export function createMusicRouter(
router.get("/personal/fm", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getPersonalFm) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -230,7 +331,8 @@ export function createMusicRouter(
router.get("/user/playlists", requireNotGuest, async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getUserPlaylists) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -245,7 +347,8 @@ export function createMusicRouter(
router.get("/playlist/:id/detail", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getPlaylistDetail) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -279,6 +382,139 @@ export function createMusicRouter(
}
});
// B站分P列表查询
router.get("/bilibili/parts", async (req, res) => {
try {
const bvid = (req.query.bvid as string)?.trim();
if (!bvid) {
res.status(400).json({ error: "bvid is required" });
return;
}
const provider = bilibiliProvider as any;
if (typeof provider.getVideoParts === "function") {
const result = await provider.getVideoParts(bvid);
if (!result) {
res.status(404).json({ error: "Video not found" });
return;
}
res.json(result);
} else {
res.status(501).json({ error: "Not supported" });
}
} catch (err) {
logger.error({ err }, "Get bilibili parts failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
router.get("/providers", (_req, res) => {
const ALL_PLATFORMS = [
"jellyfin",
"netease",
"qq",
"bilibili",
"youtube",
"kugou",
"spotify",
"local",
];
res.json({
enabled: config ? ALL_PLATFORMS.filter((p) => isProviderEnabled(config, p)) : ALL_PLATFORMS,
default: config ? defaultPlatform(config) : "netease",
});
});
// ─── Jellyfin home-page data ───────────────────────────────────────────
// The concrete JellyfinProvider surface these endpoints consume; the router
// only knows the MusicProvider interface, so narrow structurally (same
// pattern as localProvider.uploadAudio above).
type JellyfinHomeProvider = MusicProvider & {
getLatestAlbums?: (limit?: number) => Promise<Album[]>;
getMostPlayed?: (limit?: number) => Promise<Song[]>;
getFavoriteSongs?: (limit?: number) => Promise<Song[]>;
getGenres?: (limit?: number) => Promise<{ id: string; name: string }[]>;
getGenreSongs?: (genreId: string, limit?: number) => Promise<Song[]>;
};
const jellyfinHome = jellyfinProvider as JellyfinHomeProvider | undefined;
/** 501 when no provider is wired, 400 when the source is disabled. */
function jellyfinOrReject(res: Response): JellyfinHomeProvider | null {
if (!jellyfinHome) {
res.status(501).json({ error: "Jellyfin provider not configured" });
return null;
}
if (config && !isProviderEnabled(config, "jellyfin")) {
res.status(400).json({ error: "音源未启用:jellyfin (provider disabled)" });
return null;
}
return jellyfinHome;
}
router.get("/jellyfin/latest-albums", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 12;
res.json({ albums: (await p.getLatestAlbums?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin latest albums failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/most-played", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 12;
res.json({ songs: (await p.getMostPlayed?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin most played failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Favorites are account-level data — guests don't get them (matches the
// requireNotGuest gate on /user/playlists).
router.get("/jellyfin/favorites", requireNotGuest, async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 100;
res.json({ songs: (await p.getFavoriteSongs?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin favorites failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/genres", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 30;
res.json({ genres: (await p.getGenres?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin genres failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/genre/:id/songs", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 100;
res.json({ songs: (await p.getGenreSongs?.(req.params.id, limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin genre songs failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Get current quality
router.get("/quality", requireNotGuest, (_req, res) => {
res.json({
@@ -286,6 +522,9 @@ export function createMusicRouter(
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320",
jellyfin: jellyfinProvider?.getQuality() ?? "direct",
});
});
@@ -305,6 +544,37 @@ export function createMusicRouter(
if (!platform || platform === "bilibili") {
bilibiliProvider.setQuality(quality);
}
if ((!platform || platform === "kugou") && kugouProvider) {
kugouProvider.setQuality(quality);
}
if ((!platform || platform === "spotify") && spotifyProvider) {
spotifyProvider.setQuality(quality);
}
// Safe in the platform-less broadcast: JellyfinProvider.setQuality ignores
// values outside its own tier list (direct/320/192/128).
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
jellyfinProvider.setQuality(quality);
}
// Persist the (post-apply) per-provider quality so it survives a restart
// (#125). Snapshotting each provider's getQuality() AFTER setQuality captures
// exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps
// aliases), so replaying these on startup reproduces this state faithfully.
if (config && configPath) {
config.audioQuality = {
netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou,
jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin,
};
try {
saveConfig(configPath, config);
} catch (err) {
logger.warn({ err }, "Failed to persist audio quality");
}
}
logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality });
});
+41 -33
View File
@@ -39,6 +39,11 @@ export function createPlayerRouter(
if (platform === "bilibili") return "-b";
if (platform === "qq") return "-q";
if (platform === "youtube") return "-y";
if (platform === "kugou") return "-k";
if (platform === "jellyfin") return "-j";
// The flag-less default is now the configured default platform (jellyfin
// unless disabled), so netease needs an explicit flag.
if (platform === "netease") return "-n";
return "";
};
@@ -52,6 +57,11 @@ export function createPlayerRouter(
res.status(403).json({ error: "本地音频播放已关闭" });
}
function requesterName(req: any): string {
const name = req.user?.username;
return typeof name === "string" && name.trim() ? name.trim() : "游客";
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
@@ -65,7 +75,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -81,7 +91,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -115,11 +125,11 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
const message = await bot.startFm(provider);
const message = await bot.startFm(provider, requesterName(req));
res.json({
ok:
!message.startsWith("No FM songs") &&
@@ -197,7 +207,7 @@ export function createPlayerRouter(
.json({ error: "position must be a finite non-negative number" });
return;
}
bot.getPlayer().seek(position);
bot.seek(position);
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -270,7 +280,7 @@ export function createPlayerRouter(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -290,7 +300,7 @@ export function createPlayerRouter(
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
@@ -331,7 +341,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
@@ -383,7 +393,7 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
@@ -417,7 +427,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
@@ -451,7 +461,11 @@ export function createPlayerRouter(
}
});
// Play a single song by ID — resolves URL on demand
// Play a single song by ID — resolves URL on demand. Funnels through
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
// insert-and-jump, keeping the queue) lives in one place shared with chat
// !play. Serialized via runExclusive like /play-now-song so concurrent
// requests can't interleave the queue mutation + playback (#119).
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
@@ -464,23 +478,16 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res);
return;
}
const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear();
queue.add(song);
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
// Sweep AFTER the new song is queued+resolved, so replaying a local song
// that was still in the queue doesn't delete the file we're about to play.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
}
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
// The cleanupQueuedLocalSongs sweep now lives inside playSingleSong's
// clear branch — do NOT also call it here, or it would delete retained
// local uploads in keep-queue mode.
const body = await bot.runExclusive(async () => {
const ok = await bot.playSingleSong({ ...song }, requesterName(req));
return ok
? { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }
: { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
});
res.json(body);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
@@ -512,7 +519,7 @@ export function createPlayerRouter(
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
if (wasIdle) {
// Promote the just-added song to current and start it.
@@ -554,7 +561,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
@@ -586,7 +593,7 @@ export function createPlayerRouter(
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
queue.add({ ...song, requestedBy: requesterName(req) });
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
@@ -614,7 +621,7 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
@@ -626,7 +633,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
// If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") {
@@ -677,6 +684,7 @@ export function createPlayerRouter(
coverUrl: r.coverUrl,
platform: r.platform,
playedAt: r.playedAt,
requestedBy: r.requestedBy,
}));
res.json({ history });
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase } from "../../data/database.js";
import type { BotManager } from "../../bot/manager.js";
import { createSavedQueuesRouter } from "./saved-queues.js";
const song = (id: string) => ({
id,
name: id,
artist: "",
album: "",
platform: "netease" as const,
coverUrl: "",
duration: 1,
});
function mount(enabled: boolean, opts: { queue?: unknown[] } = {}) {
const db = createDatabase(":memory:");
const loads: Array<{ songs: unknown[]; mode: string; by?: string }> = [];
const bot = {
getQueueManager: () => ({ list: () => opts.queue ?? [song("a"), song("b")] }),
loadSavedQueue: async (songs: unknown[], mode: string, by?: string) => {
loads.push({ songs, mode, by });
},
};
const botManager = { getBot: (_id: string) => bot } as unknown as BotManager;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: unknown }).user = { id: "u1", username: "alice", role: "member" };
next();
});
app.use(
"/api/saved-queues",
createSavedQueuesRouter(db, botManager, () => enabled, pino({ level: "silent" })),
);
return { app, db, loads };
}
describe("saved-queues router", () => {
it("403s every route when the feature is disabled", async () => {
const { app } = mount(false);
expect((await request(app).get("/api/saved-queues")).status).toBe(403);
expect((await request(app).post("/api/saved-queues").send({ botId: "b", name: "x" })).status).toBe(403);
expect((await request(app).post("/api/saved-queues/1/load").send({ botId: "b" })).status).toBe(403);
expect((await request(app).delete("/api/saved-queues/1")).status).toBe(403);
});
it("saves the current queue (private) and lists it back", async () => {
const { app } = mount(true);
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "night" });
expect(save.status).toBe(200);
expect(save.body.queue.name).toBe("night");
expect(save.body.queue.songCount).toBe(2);
expect(save.body.queue.ownerId).toBe("u1");
const list = await request(app).get("/api/saved-queues");
expect(list.status).toBe(200);
expect(list.body.queues.map((q: { name: string }) => q.name)).toContain("night");
});
it("saves to the shared bucket when shared:true", async () => {
const { app, db } = mount(true);
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "party", shared: true });
expect(save.status).toBe(200);
expect(save.body.queue.ownerId).toBe(SHARED_QUEUE_OWNER);
expect(db.listSavedQueues(SHARED_QUEUE_OWNER, false).map((q) => q.name)).toEqual(["party"]);
});
it("rejects saving an empty queue", async () => {
const { app } = mount(true, { queue: [] });
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "empty" });
expect(save.status).toBe(400);
});
it("requires botId and name", async () => {
const { app } = mount(true);
expect((await request(app).post("/api/saved-queues").send({ name: "x" })).status).toBe(400);
expect((await request(app).post("/api/saved-queues").send({ botId: "b" })).status).toBe(400);
});
it("loads a shared queue (replace by default) into the bot", async () => {
const { app, db, loads } = mount(true);
const saved = db.saveQueue(SHARED_QUEUE_OWNER, "party", [song("a"), song("b")]);
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b" });
expect(load.status).toBe(200);
expect(load.body).toMatchObject({ ok: true, loaded: 2, mode: "replace" });
expect(loads).toHaveLength(1);
expect(loads[0].mode).toBe("replace");
expect(loads[0].by).toBe("alice");
});
it("loads in append mode when requested", async () => {
const { app, db, loads } = mount(true);
const saved = db.saveQueue("u1", "mine", [song("a")]);
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b", mode: "append" });
expect(load.status).toBe(200);
expect(loads[0].mode).toBe("append");
});
it("404s loading another user's private queue (no existence leak)", async () => {
const { app, db } = mount(true);
db.saveQueue("someoneElse", "private", [song("z")]);
const other = db.listSavedQueues("someoneElse", false)[0];
const load = await request(app).post(`/api/saved-queues/${other.id}/load`).send({ botId: "b", mode: "replace" });
expect(load.status).toBe(404);
});
it("deletes an own queue but 404s another user's private one", async () => {
const { app, db } = mount(true);
const mine = db.saveQueue("u1", "mine", [song("a")]);
const theirs = db.saveQueue("someoneElse", "private", [song("z")]);
expect((await request(app).delete(`/api/saved-queues/${theirs.id}`)).status).toBe(404);
expect((await request(app).delete(`/api/saved-queues/${mine.id}`)).status).toBe(200);
expect(db.getSavedQueue(mine.id)).toBeNull();
});
});
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more