Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANG e5879ea106 docs: describe v1.15.1 playback and privacy fixes 2026-10-03 21:03:06 +08:00
TIANYAO ZHANG 44a1457baa fix: isolate embedded music API credential logs 2026-10-03 21:03:05 +08:00
TIANYAO ZHANG a4c0239a59 fix: drain FFmpeg stderr and sanitize playback diagnostics 2026-10-03 21:03:04 +08:00
TIANYAO ZHANG 5258ba951b chore: prepare v1.15.0 release and exclude generated test copies 2026-10-03 17:25:02 +08:00
TIANYAO ZHANG d16aca2ba6 fix: honor artist shuffle permissions and discard stale page requests 2026-10-03 17:25:01 +08:00
TIANYAO ZHANG 9bcddb1881 fix: preserve pause intent and deduplicate stream recovery lookups 2026-10-03 17:17:53 +08:00
TIANYAO ZHANG cf83916f39 fix: serialize artist playback and keep incomplete QQ catalogs retryable 2026-10-03 17:17:35 +08:00
TIANYAO ZHANG f8b03acca3 fix: fence profile updates and check TeamSpeak permission failures 2026-10-03 17:17:35 +08:00
TIANYAO ZHANG b9c79c8138 fix: revoke API keys on password rotation and audit key owners 2026-10-03 17:17:34 +08:00
TIANYAO ZHANG 79fb8443be fix: fence stream recovery and EOF advancement by playback session 2026-10-03 16:55:34 +08:00
TIANYAO ZHANG c904190912 Merge pull request #170 from ZHANGTIANYAO1/fix/issue-161-bilibili-long-stream
# Conflicts:
#	src/bot/instance.test.ts
2026-10-03 16:50:49 +08:00
TIANYAO ZHANG 41b81a6193 Merge pull request #175 from zzstar101/feat/artist-search 2026-10-03 16:50:09 +08:00
TIANYAO ZHANG f495ca0ff4 Merge pull request #174 from razaxq/main 2026-10-03 16:50:09 +08:00
TIANYAO ZHANG bdb33df89d Merge pull request #173 from senlinjun/feat/restapi 2026-10-03 16:50:09 +08:00
TIANYAO ZHANG 3423bf502c docs: plan reviewed PR fixes and release validation 2026-10-03 16:50:08 +08:00
zzstar101 b6ad536bb7 feat(web): artist search, artist pages, and full-catalogue playback
Adds artist support for the NetEase and QQ providers plus the matching UI.

Backend:
- SearchResult gains `artists`; new optional MusicProvider methods
  getArtistDetail / getArtistSongs / getArtistAlbums / getArtistAllSongs.
- NetEase: /cloudsearch type=100 for artist search, /artists, /artist/songs,
  /artist/album and /artist/desc for the artist page.
- QQ: singer search rides along in the existing musicu.fcg batch
  (search_type=1); singer detail via music.web_singer_info_svr. QQ exposes no
  working singer-song paging endpoint, so the full catalogue is built from the
  hot 50 plus every album of the singer (album search filtered by singerMID,
  songs fetched per album, de-duplicated, cached for 10 minutes). A failed
  album sweep is never cached and degrades to the hot list.
- API: GET /api/music/artist/:id and POST /api/player/:botId/play-artist
  (player.control capability, guest flag playCollection); /search/all now
  aggregates artists too.

Frontend:
- Search history in localStorage (max 10, per platform, never shared between
  users), shown as a dropdown under the search box and as 最近搜索 chips.
- Artist row in the search results; new /artist/:id page (portrait, aliases,
  stats, description, top songs, album shelf) with 播放 / 随机播放, which queue
  the singer's whole catalogue.
- playArtist store action.

Tests cover the provider mappers, the new routes, the play-artist collector
(paging, de-duplication, 500-track cap), permission gating and the new views.
2026-10-02 01:36:03 +08:00
razaxq af4ca56fd3 fix(ts6): update the real music client profile 2026-10-01 21:32:21 +08:00
senlinjun bf7858db74 docs(api): document /api/me/music, bilibili parts and personal-FM behavior from v1.14.0
- new /api/me/music section (per-user NetEase account linking, key-compatible)
- GET /api/music/bilibili/parts endpoint
- /api/player/:botId/fm note: prefers the caller's own linked NetEase account
2026-09-29 21:55:53 +08:00
senlinjun e4eea8276a Merge remote-tracking branch 'origin/main' 2026-09-29 21:52:14 +08:00
senlinjun aab8a004ae feat(web): add API-key authentication for the REST API
- api_keys table + hashed key store (src/data/api-keys.ts), tsmb_-prefixed
  plaintext shown once, per-user cap of 20, lastUsedAt tracking
- requireAuth accepts Authorization: Bearer / X-API-Key headers as an
  alternative to the session cookie; key inherits the owner user's
  role/capabilities/bot scope
- csrf origin check skipped for key-only requests (no ambient credentials);
  requests that also carry the session cookie stay gated
- /api/keys management endpoints (session-only, guests excluded, keys
  themselves rejected) with audit logging
- user deletion / password reset cascade-revoke the user's keys
- Settings page: API key management section (create/copy-once/revoke)
- docs: README section + full endpoint reference in docs/API.md
2026-09-29 21:51:43 +08:00
TIANYAO ZHANGandClaude Opus 5.5 87fca6d8b7 docs: add v1.14.0 changelog entry
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 23:46:18 +08:00
TIANYAO ZHANG c7b1268281 Merge pull request #172 from ZHANGTIANYAO1/fix/issue-165-install-scripts
fix(install): bring install.sh up to Node 22 and document both Linux scripts (#165)
2026-09-27 23:44:44 +08:00
TIANYAO ZHANG b942a4726e Merge pull request #171 from ZHANGTIANYAO1/feat/issue-164-personal-netease-fm
feat(fm): let each web user link their own NetEase account for personal FM (#164)
2026-09-27 23:44:35 +08:00
TIANYAO ZHANG ad728a3a04 Merge pull request #169 from ZHANGTIANYAO1/feat/issue-160-playlist-link
feat(playlist): load a playlist straight from its link (#160)
2026-09-27 23:44:28 +08:00
TIANYAO ZHANG c51d311ab6 Merge pull request #168 from ZHANGTIANYAO1/fix/issue-159-channel-desc-on-move
fix(profile): move the now-playing channel description with the bot (#159)
2026-09-27 23:44:20 +08:00
TIANYAO ZHANGandClaude Opus 5.5 ac4a12d8bd feat(fm): let each web user link their own NetEase account for personal FM (#164)
With several people sharing one bot, personal FM always followed the one
account the bot was logged in with. Each signed-in (non-guest) web user
can now scan a QR code under Settings → 账户 to link their own NetEase
account; FM they start from the WebUI then comes from their account.

- user_music_cookies table (per user + platform, dropped with the user).
- NeteaseProvider.pollQrLogin returns the cookie without storing it, so
  a personal login can never replace the bot's shared account;
  checkQrCodeStatus is now built on it. withCookie gives a view bound to
  another account.
- /api/me/music/netease: status / qrcode / qrcode/status / unlink, acting
  only on req.user. The cookie never leaves the server.
- POST /api/player/:botId/fm uses the caller's linked account for
  NetEase. Songs still resolve through the shared provider when played.

TeamSpeak chat !fm keeps using the shared account: chat users are not
tied to web accounts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 22:02:31 +08:00
TIANYAO ZHANGandClaude Opus 5.5 07ad861ecf fix(bilibili): keep long videos playing when the CDN drops the stream (#161)
Long B站 videos (2-3 h) still stopped ~15-20 min in, the same symptom as
#89. Reconnecting to the same URL is not enough once the CDN session is
gone, so:

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:52:47 +08:00
TIANYAO ZHANGandClaude Opus 5.5 88e6b5a691 fix(install): bring install.sh up to Node 22 and document both Linux scripts (#165)
install.sh still installed Node 20 (dropped in #152), ran the Debian-only
NodeSource script on yum systems, and hard-coded /usr/bin/node. The
README only mentioned install.sh, not setup.sh.

install.sh now:
- installs Node 22 LTS from the right NodeSource repo per distro and
  checks the same 22.12+/24+ floor as setup.sh
- delegates npm install, mirror detection, native-binary checks and the
  build to setup.sh, so the two scripts share one install path
- stops the service and replaces dist/node_modules on re-install (data/
  is kept), copies bin/ (yt-dlp), and uses the real node path in the unit

README explains the difference between the two scripts and when to use
which. setup.sh's "Node.js not found" message no longer says 20+.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:42:59 +08:00
TIANYAO ZHANGandClaude Opus 5.5 6b82df4e50 feat(playlist): load a playlist straight from its link (#160)
`!playlist` already pulled a numeric id out of a URL, but the platform
still came from flags, so a QQ link without -q was looked up on NetEase,
and a YouTube ?list= link fell through to a name search on the URL.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:41:05 +08:00
TIANYAO 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 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
117 changed files with 17888 additions and 809 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*"
],
"deny": [
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
-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 /)"
]
}
}
+1
View File
@@ -7,6 +7,7 @@ config.json
cookies/
.superpowers/
.worktrees/
.claude/
setup.log
/bin/
scripts/navbar_bigger.png
+269 -21
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Node.js-20+-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/Node.js-20%20%7C%2022%20LTS-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/许可证-MIT-green" />
@@ -31,7 +31,8 @@
- **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 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
@@ -41,7 +42,7 @@
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号)
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
@@ -61,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
@@ -130,14 +136,35 @@ ports:
</details>
### 方式四:Linux 一键安装
### 方式四:Linux 安装脚本
Linux 下有两个脚本,按需二选一:
| | `scripts/install.sh`(一键安装 + 系统服务) | `scripts/setup.sh`(只安装构建) |
|---|---|---|
| 适合 | 想开箱即用、开机自启的服务器 | 想自己决定怎么常驻(screen / tmux / pm2 / 自写服务)的用户,或 macOS |
| Node.js | 没有或版本过低时**自动安装 Node 22 LTS**(apt / yum / pacman) | **不会安装**,需先自行装好 Node 22.12+ |
| 系统依赖 | 自动安装构建工具(和 FFmpeg,作为内置 FFmpeg 的后备) | 不安装,只提示 |
| 安装位置 | 构建后复制到 `/opt/tsmusicbot`(重装时保留 `data/`) | 就在当前项目目录 |
| 系统服务 | 自动配置 systemd 服务 `tsmusicbot` 并开机自启 | **不配置服务**,完成后自己 `npm start` |
| 需要 root | 是(`sudo`) | 否 |
两者共用同一套安装逻辑:`install.sh` 会调用 `setup.sh` 完成依赖安装、国内网络镜像切换、原生模块校验和构建,然后再复制文件、配置服务。
**一键安装 + systemd 服务:**
```bash
chmod +x scripts/install.sh
sudo ./scripts/install.sh
# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f
```
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
**只安装构建(不装 Node、不配服务):**
```bash
bash scripts/setup.sh
npm start
```
## 更新升级
@@ -329,7 +356,8 @@ sudo systemctl start tsmusicbot
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
| **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -346,8 +374,8 @@ sudo systemctl start tsmusicbot
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!play id <id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:<id>` 仍然可用 |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id <id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
@@ -357,6 +385,7 @@ sudo systemctl start tsmusicbot
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认网易云,自动续播) |
@@ -367,9 +396,14 @@ sudo systemctl start tsmusicbot
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 |
| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) |
| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) |
| `!queues` | 列出已保存(共享)清单 |
| `!help` | 显示帮助信息 |
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
>
> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。
### TeamSpeak 命令权限(管理类命令限制)
@@ -414,6 +448,66 @@ sudo systemctl start tsmusicbot
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。
### 重启后保留的播放设置
以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值:
| 设置 | 作用范围 | 存储位置 |
|------|----------|----------|
| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` |
| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` |
| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` |
聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。
## REST API(API Key)
除浏览器 session 登录外,REST API 还支持用 **API Key** 调用,便于脚本、Home Assistant 等外部集成。
### 创建 Key
登录 WebUI → 设置页 → 「API 密钥」→ 输入名称 → 生成。明文**只在创建时显示一次**(形如 `tsmb_xxxxx…`),之后只能看到前缀;可随时在设置页吊销。Key 的权限与所属账户一致:管理员拥有全部权限,成员只能操作被授权的机器人、使用被授予的能力(播放控制 / 队列管理等)。每位用户最多创建 20 个 Key。
### 调用方式
两种请求头任选其一:
```
Authorization: Bearer tsmb_xxxxxxxxxxxx
X-API-Key: tsmb_xxxxxxxxxxxx
```
> 修改类请求(POST/PUT/DELETE)无需 CSRF Origin 头;WebSocket 推送(`/ws`)暂不支持 API Key,仅限浏览器 session。
### 常用端点示例
```bash
# 机器人列表(拿到 botId)
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/bot
# 当前队列 + 播放状态
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/player/<botId>/queue
# 点歌(搜索文本 + 平台:netease/qq/bilibili/youtube/kugou/jellyfin/local)
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"query":"周杰伦 晴天","platform":"netease"}' \
http://127.0.0.1:3000/api/player/<botId>/play
# 搜索歌曲(拿 song id / song 对象)
curl -H "Authorization: Bearer $KEY" \
"http://127.0.0.1:3000/api/music/search?q=晴天&platform=netease"
# 播放控制
curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player/<botId>/pause
curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player/<botId>/next
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"volume":50}' http://127.0.0.1:3000/api/player/<botId>/volume
```
全部端点、参数与返回值见 **[docs/API.md](docs/API.md)**。认证失败返回 `401 {"error":"invalid api key"}`,越权返回 `403`。
## 项目架构
```
@@ -469,6 +563,7 @@ teamspeak-music-bot/
├── scripts/ # 部署脚本
│ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
@@ -484,7 +579,7 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
| **运行时** | Node.js 20+, TypeScript 5 |
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
@@ -543,7 +638,7 @@ teamspeak-music-bot/
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id <id>` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
### enabledProviders:音源开关
@@ -551,9 +646,10 @@ teamspeak-music-bot/
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源
@@ -775,11 +871,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 <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -854,7 +953,155 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 — Jellyfin 可选音源
### 最新版本 — v1.15.1:长视频播放阻塞与音乐 API 日志隐私修复
- 持续读取 FFmpeg 的 stderr,并关闭周期性进度输出,避免错误输出管道写满后卡住音频解码。直接 URL 播放和 Windows 临时文件播放均已处理。
- FFmpeg 异常退出和播放停滞日志增加限长、脱敏的诊断摘要;移除 URL 查询参数、用户凭据和认证头,PowerShell 下载失败日志采用同样的处理。
- 内置网易云 / QQ 音乐 API 在独立子进程运行,隔离依赖直接输出的原始请求和响应日志,避免其中的 Cookie 等凭据进入机器人控制台日志。仍保留服务启动和退出的安全诊断;独立部署或已占用端口的外部 API 需自行管理日志。
无配置或数据库迁移。此补丁修复了已复现的管道阻塞;[#161](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/161) 中“67 分钟视频播到 37 分钟停止”的现场原因仍缺少停止时日志,尚未确认。
### v1.15.0:歌手页面 / REST API / TS6 Profile 与 B站续播修复
**歌手搜索与页面([PR #175](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/175),感谢 [@zzstar101](https://github.com/zzstar101))**
- 网易云 / QQ 音乐支持搜索歌手、查看歌手介绍、热门歌曲和专辑,并播放或随机播放歌手曲目(最多 500 首)。搜索历史按音源保存在当前浏览器。
- 歌手播放与单曲播放共用播放锁,避免同时点播时实际歌曲与队列不一致。QQ 曲目目录在上游查询失败时不缓存降级结果,不再因 50 张专辑的限制提前截断歌曲。
- 歌手页面的迟到请求不会覆盖新页面;访客的随机播放按钮同时遵守歌手播放与模式切换权限。
**REST API([PR #173](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/173),感谢 [@senlinjun](https://github.com/senlinjun))**
- 在设置页创建、查看和撤销 API Key;脚本可用 Bearer 或 X-API-Key 调用已有 REST 端点,权限和可控机器人范围继承所属用户。完整说明见 [REST API 文档](docs/API.md)。
- 修复管理员撤销他人 Key 时的审计对象。修改或重置密码会撤销该用户的全部 API Key,外部集成需要重新生成凭据。
**TS6 Profile([PR #174](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/174),感谢 [@razaxq](https://github.com/razaxq))**
- 昵称和 Away 状态通过真实音乐客户端更新,描述通过明确的客户端 ID 更新,避免修改 HTTP ServerQuery 客户端。
- 频道描述写入和移动后的清理使用相同权限路径;重连后丢弃旧会话请求,权限不足时可靠降级。
**B站长视频续播([#161](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/161),[PR #170](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/170))**
- 优先使用稳定 CDN 镜像;流提前结束时重新解析地址并从当前进度续播,连续无进展重试有次数限制。
- 播放结束和恢复请求按播放会话校验,旧请求不会跳过新曲,也不会覆盖同一曲目的新一轮播放。
- 恢复地址查询期间暂停会保留暂停状态;恢复播放不会重复发起查询,查询失败后仍可按重试上限继续恢复。
数据库自动新增 API Key 表,保留已有用户和设置。自动化测试只收集源码,排除旧的编译测试副本。
### v1.14.0:歌单链接直接播放 / 每人绑定自己的网易云私人FM / B站分P
处理了 5 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;数据库会自动新增一张表(存放用户自己绑定的网易云账号),原有数据不受影响。
**`!playlist` 直接粘贴歌单链接([#160](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/160),[PR #169](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/169),感谢 [@JiaxiangACE](https://github.com/JiaxiangACE))**
- `!playlist <歌单链接>` 支持网易云 / QQ 音乐 / YouTube 歌单链接,**平台由链接自动识别**:以前 QQ 链接不加 `-q` 会被拿去网易云查,YouTube 的 `?list=` 链接会被当成歌单名去搜索,现在都能直接用。
- App 里「分享」复制出来的整段文案、以及短链(`163cn.tv`、`c6.y.qq.com`)也能直接粘贴。短链只会访问这两个域名,不会去请求任意用户给的地址。
- 歌单名和纯数字 ID 的用法不变。
**每个网页端用户绑定自己的网易云账号听私人FM([#164](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/164),[PR #171](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/171),感谢 [@xxmod](https://github.com/xxmod))**
- 多人共用一个机器人时,私人FM以前永远按机器人登录的那一个账号推荐。现在每个成员可以在 **设置 → 账户** 扫码绑定自己的网易云账号,之后他在网页端开启的网易云私人FM按他自己的口味推荐;未绑定的人照旧使用共享账号。
- 绑定的登录只保存在服务器上,从不回传给浏览器,也**不会**顶掉机器人的共享登录;删除用户时一并清除。游客不能绑定。
- TS 聊天里的 `!fm` 仍使用共享账号(聊天里的 TS 用户和网页账号没有对应关系)。
**机器人被移动后,原频道描述不再残留([#159](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/159),[PR #168](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/168),感谢 [@Almighty-ap](https://github.com/Almighty-ap))**
- 开启「更新频道描述」时,把机器人拖到别的频道后,原频道会一直停留在当时的歌曲信息。现在机器人被移动时会清空原频道描述,并把正在播放的信息写到新频道。
**Linux 安装脚本([#165](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/165),[PR #172](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/172),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
- `scripts/install.sh` 以前仍在安装已不再支持的 Node 20,现在按发行版(apt / yum / pacman)安装 Node 22 LTS,并复用 `setup.sh` 完成依赖安装、国内镜像切换、原生模块校验和构建。重复运行(升级)时会先停服务、替换构建产物,**保留 `data/`**。
- README 的「Linux 安装脚本」一节说明了 `install.sh`(一键安装 + systemd 开机自启)和 `setup.sh`(只安装构建、不装 Node、不配服务)的区别和适用场景。
**B站分P视频([PR #166](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/166),感谢 [@xxmod](https://github.com/xxmod))**
- 分P视频以前只能播放第一P,且时长显示为整个视频的总时长。现在网页端播放多P视频时会弹出选择框选P;TS 里 `!play` 播放第一P。
### v1.13.0:本地视频上传播放 / 头像上传时机
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
**本地视频上传播放([#149](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/149),[PR #151](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/151),感谢 [@LadenceE](https://github.com/LadenceE))**
- 搜索页的本地上传现在也收**视频文件**:mp4 / mov / avi / mkv / flv / wmv / m4v / mpg / mpeg / 3gp / ts / m2ts / ogv。上传后当普通歌曲用——直接播放、下一首播放、加入队列都一样。
- 视频**只保留音轨**:上传后立刻把音频流原样搬进一个音频容器(不重编码、无损),再删掉原视频。720p 素材实测落盘只剩原文件的 14%,不然十几个视频就把 5 GiB 的上传目录配额占满了。
- 没有音轨的视频会在**上传时**就被拒绝并说明原因,而不是排进队列后静默跳过。
- 单文件上限从 200 MB 提到 **500 MB**;超限时的报错从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成正常的中文提示,浏览器端也会在开传前就拦下超大文件。
- 上传进度按文件显示百分比,传完切到「服务端处理中」——视频比音频大得多,原先那句静止的「正在上传」看着像卡死。
- 说明:这里做的是「把你本地磁盘上的文件传上来播放」。让机器人直接读取**服务器**磁盘上任意路径的文件没有做——那等于开一个全盘任意文件读取的口子,而「播放服务器上已有的媒体库」用 Jellyfin 音源即可。
**初始化阶段不再发起注定失败的头像上传([#148](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/148),[PR #150](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/150),感谢 [@shenmu-rua](https://github.com/shenmu-rua))**
- 机器人构造阶段读出已保存的自定义头像后会立刻发起文件传输,但那时 TeamSpeak 还没连上,这次传输必定失败。现在构造阶段只把头像数据装入内存,实际上传交给连接成功后的 `onConnect()`。
- **影响范围说明**:头像本身一直是能正常显示的(连接成功后本来就会重新应用一次),所以这不是「头像丢了」。真正的代价是每次启动 / 重启都会多一次注定失败的请求和一条 `Profile update failed` 警告日志——现在没有了。
- 顺带修掉一个边角:头像文件写到一半崩溃会留下 0 字节文件,原先这会再触发两个同样注定失败的请求。
### v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈
一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。
**安装脚本按 Node ABI 校验并自动修复([#140](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/140),[PR #147](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/147),感谢 [@zbn297427669](https://github.com/zbn297427669))**
- 换过 Node 大版本后启动报 `NODE_MODULE_VERSION 137 ... requires 127`、或提示找不到 `opus.node` 的问题已修复。原生模块只能在编译它的 Node ABI 上加载,而旧脚本只检查「文件存在且够大」,会把给另一个 Node 版本编译的二进制原样留下。
- 现在安装脚本会在子进程里真的加载一遍每个原生模块,不匹配就按当前 ABI 重新安装;替换过程先备份再原子替换,任何一步失败都会把原文件逐字节还原,不会让环境变得更糟。被中断留下的备份,下次运行自动认领回来。
- 必需模块失败会**中止安装并返回非零**,不再出现「setup 显示成功、start 才爆炸」;下载进度实时显示在控制台,不再让人以为卡死。
- `start.bat` 和 `npm start` 启动前会预检,直接说清楚哪个模块对不上、分别是哪个 ABI、怎么修。
- 顺带修掉一个会**静默丢掉 ffmpeg** 的问题:源码编译会阻塞事件循环,把同时进行的 80MB ffmpeg 下载误判为超时,而 ffmpeg 是可选模块,于是安装照样报告成功、用户却放不出任何声音。三个模块改为串行处理。
- Node 版本要求按依赖真实下限判断(20.19+ / 22.12+),推荐 20 或 22 LTS;更新的大版本不阻止,只提示可能需要源码编译。
**随机模式下 `!pn` 真正下一首播放([#141](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/141),[PR #144](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/144),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
- 随机 / 随机循环下 `!pn`(以及 WebUI 的「下一首播放」)插入的歌只是和其他歌一样等着被随机抽中,机器人却回复「Up next」。现在会真的下一首播放,连续插入多首时的顺序与队列里显示的一致。
- 同时修掉两个相关问题:插入或删除队列中的歌之后,待播位置可能指向另一首歌;删得多了甚至会让播放**静默停止**。
**WebUI 站点图标与移动端交互([#142](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/142) / [#143](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/143) / [#138](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/138),[PR #146](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/146),感谢 [@XuVIIJay](https://github.com/XuVIIJay) 与 [@hak5ya](https://github.com/hak5ya))**
- 新增站点图标:收藏网页、移动端添加到主屏幕都会显示图标(含 iOS 与 Android 适配)。
- 移动端迷你播放器的进度条现在**可以点按和拖动调节进度**,触摸区域也放大到可用尺寸,拖动时不会被自动跳转到歌词页。
- 移动端**单击歌曲行即可播放**(桌面端双击行为不变);队列抽屉里的歌曲行同样支持,其移除按钮在触屏下不再是「看不见但点得到」。
- QQ 扫码登录的提示改为「请使用手机QQ扫码」——那是 QQ 账号二维码,用 QQ音乐 APP 扫不出来。
**`!play id <id>` 与其他命令语法统一([#139](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/139),[PR #145](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/145),感谢 [@hak5ya](https://github.com/hak5ya))**
- 按 id 播放现在可以写成 `!play id <id>`,和其他命令的 `<命令> <子命令> <参数>` 形式一致;`!add` / `!playnext` 同样适用。
- **旧写法 `!play id:<id>` 继续支持**。空格写法只在参数确实像 id 时生效,普通搜索和粘贴链接的行为不受影响。
### v1.11.2 — 可配置语音闪避
- 检测频道内其他人说话时平滑降低音乐音量,停止后平滑恢复;默认关闭,可在设置页启用并调节说话时保留的音量比例([#136](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/136),[PR #137](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/137))。
### v1.11.1:修复 `!help` 触发机器人自动点歌
**丢弃自回显消息([PR #135](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/135),感谢 [@EvolvedGhost](https://github.com/EvolvedGhost))**
- 修复输入 `!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) 贡献;随后调整为可选音源)**
@@ -894,6 +1141,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 自动更新与协议层升级
+374
View File
@@ -0,0 +1,374 @@
# REST API 参考
本文档列出机器人对外提供的全部 REST API 端点、参数与返回。所有端点均支持两种认证方式(见下),除单独标注「仅浏览器 session」的端点外。
## 通用约定
### 认证
```
Authorization: Bearer tsmb_xxxxxxxxxxxx
# 或
X-API-Key: tsmb_xxxxxxxxxxxx
```
Key 在 WebUI 设置页创建,权限与所属账户一致。除标注「仅浏览器 session」的端点外,API Key 与浏览器 session(cookie)使用相同的账户权限。
管理员 API Key 保留完整的 REST 管理权限,包括 `/api/users` 的创建用户、重置密码与权限变更;因此也可以创建新的可登录账户。`/api/keys` 的 session 限制只约束直接密钥管理,不能作为管理员 Key 的权限隔离措施。
### 密钥吊销与密码变更
API Key 没有自动到期时间,可在设置页随时吊销。删除账户会同时删除其全部 Key。成功修改自己的密码或由管理员重置密码,都会吊销该账户的全部 Key;依赖这些 Key 的外部集成需要重新生成并更新凭据。失败的密码变更不会吊销 Key。
修改自己的密码会保留当前浏览器 session,使其余 session 失效。管理员重置其他账户的密码会使目标账户的全部 session 失效;重置自己的密码时同样保留当前浏览器 session。
### 错误格式
所有错误返回统一为 JSON `{ "error": "..." }`:
| 状态码 | 含义 |
|--------|------|
| 400 | 参数缺失或格式错误 |
| 401 | 未认证 / API Key 无效(`invalid api key`) |
| 403 | 无权限(能力不足、机器人未授权、API Key 试图管理 Key 等) |
| 404 | 资源不存在 |
| 409 | 冲突(如收藏已存在、Key 数量达上限) |
| 500 | 服务器内部错误 |
### 权限模型
| 标注 | 含义 |
|------|------|
| 公开 | 无需认证 |
| 已认证 | 任意登录用户 / 有效 API Key |
| 非游客 | API Key 用户恒满足(guest session 除外) |
| `player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality` | 需要账户持有对应能力;管理员恒通过 |
| 机器人访问 | 成员只能操作被授予的机器人(账户权限中的 bot 范围),管理员不限 |
| 管理员 | 仅 `role=admin` |
### 平台(platform)取值
`netease` / `qq` / `bilibili` / `youtube` / `kugou` / `jellyfin` / `local` / `spotify`
省略 `platform` 时使用设置页配置的默认音源;已禁用的音源返回 `400 音源未启用`。
### 点歌归属
`/play`、`/add`、`/play-*`、`/add-*` 等入队端点会把 `requestedBy` 记为 Key 所属账户的用户名,队列与播放历史中可见。
---
## 数据模型
```ts
// 歌曲(搜索结果 / 队列元素)
interface Song {
id: string; // 平台内歌曲 id
name: string;
artist: string;
album: string;
duration: number; // 秒
coverUrl: string;
platform: Platform;
vip?: boolean; // VIP/版权受限(仅试听)
}
// 队列中的歌曲(Song + 归属;url 仅播放时内部解析,不出现在响应里)
interface QueuedSong extends Omit<Song, "vip"> {
requestedBy?: string;
}
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount: number; platform: Platform }
interface Playlist { id: string; name: string; coverUrl: string; songCount: number; platform: Platform }
// 机器人实时状态
interface BotStatus {
id: string;
name: string;
connected: boolean;
playing: boolean;
paused: boolean;
currentSong: QueuedSong | null;
queueSize: number;
volume: number; // 0-100
playMode: "seq" | "loop" | "random" | "rloop";
elapsed: number; // 当前曲目已播秒数
effectiveDuration?: number; // 当前曲实际播放时长(试听片段=试听秒数)
}
```
---
## 公开端点(无需认证)
### GET /api/health
```json
{ "status": "ok", "version": "0.1.0" }
```
### GET /api/config/public-url
```json
{ "publicUrl": "https://bot.example.com" } // 未配置时为 null
```
---
## 机器人管理 /api/bot
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/api/bot` | 已认证 | 机器人列表(成员只返回被授权的) |
| GET | `/api/bot/settings` | 非游客 | 全局行为设置 |
| POST | `/api/bot/settings` | `bot.manage` | 保存全局设置(部分合并) |
| POST | `/api/bot` | `bot.manage` | 创建机器人 |
| GET | `/api/bot/:id` | 机器人访问 | 单个机器人状态 |
| PUT | `/api/bot/:id` | `bot.manage` + 机器人访问 | 更新连接配置 |
| DELETE | `/api/bot/:id` | `bot.manage` + 机器人访问 | 删除机器人 |
| POST | `/api/bot/:id/start` | `bot.manage` + 机器人访问 | 连接服务器 |
| POST | `/api/bot/:id/stop` | `bot.manage` + 机器人访问 | 断开连接 |
| GET | `/api/bot/:id/config` | `bot.manage` + 机器人访问 | 保存的连接配置(不含 identity/TS6 key) |
| GET / PUT / DELETE | `/api/bot/:id/avatar` | `bot.manage` + 机器人访问 | 自定义头像 |
### GET /api/bot
```json
{ "bots": [ { "id": "…", "name": "客厅bot", "connected": true, "playing": true, "paused": false,
"currentSong": { "…": "QueuedSong" }, "queueSize": 3, "volume": 75,
"playMode": "seq", "elapsed": 42.5, "effectiveDuration": 269 } ] }
```
### POST /api/bot
```json
// 请求体(name、serverAddress、nickname 必填;serverPort 默认 9987)
{ "name": "客厅bot", "serverAddress": "ts.example.com", "serverPort": 9987,
"nickname": "♪ 音乐机器人", "defaultChannel": "音乐频道", "channelId": "12",
"channelPassword": "", "serverPassword": "", "autoStart": true }
// 201 返回 BotStatus
```
### PUT /api/bot/:id
请求体字段同上(全部可选),返回 `{ "success": true }`。连接相关修改需重启机器人(`stop` 后 `start`)生效。
### POST /api/bot/settings(部分合并,未传的字段不变)
```json
{
"idleTimeoutMinutes": 30, // 空闲自动断开,0=不启用
"autoPauseOnEmpty": true, // 频道无人自动暂停
"localAudioEnabled": true, // 本地音频
"voiceDucking": { "enabled": true, "volumePercent": 20 },
"savedQueuesEnabled": true,
"playKeepsQueue": false, // !play 是否保留队列
"adminGroups": [6],
"enabledProviders": ["netease","qq","bilibili","youtube","kugou"],
"defaultPlatform": "netease", // null/"" 清除
"guestMode": { "enabled": false, "bots": "all", "permissions": { "…": true } },
"spotify": { "enabled": false, "clientId": "…", "clientSecret": "…", "backend": "auto", "bitrate": 160, "deviceName": "…" },
"jellyfin": { "serverUrl": "…", "authMode": "userpass", "username": "…", "password": "…" }
}
// 返回:与 GET /settings 相同结构(spotify.clientSecret / jellyfin.password 永不回传,仅 hasClientSecret / hasPassword 布尔)
```
### PUT /api/bot/:id/avatar
请求体 `{ "dataUrl": "data:image/png;base64,…" }`(png/jpeg/webp,≤200KB),返回 `{ "path": "avatars/xx.png" }`。
---
## 播放控制 /api/player/:botId
以下所有端点都要求机器人访问权限;标注能力的管理类操作还需对应能力。`{ "message": "…" }` 为命令执行回执文本(与聊天命令回执一致),失败时 message 中带原因或返回 4xx/5xx。
### 播放入口
| 方法 | 路径 | 能力 | 请求体 | 返回 |
|------|------|------|--------|------|
| POST | `/play` | `player.control` | `{ query, platform? }`(搜索文本) | `{ message }` |
| POST | `/add` | `player.queue` | `{ query, platform? }` | `{ message }` |
| POST | `/play-song` | `player.control` | `{ song }`(Song 对象,清空队列播放) | `{ ok, message }` |
| POST | `/play-now-song` | `player.control` | `{ song }`(插入当前曲后立即播放,保留队列) | `{ ok, message }` |
| POST | `/play-next-song` | `player.control` | `{ song }`(插播下一首;空闲时直接播放) | `{ ok, message }` |
| POST | `/add-song` | `player.queue` | `{ song }`(入队;空闲时立即播放) | `{ message }` |
| POST | `/add-by-id` | `player.queue` | `{ songId, platform? }` | `{ message }` |
| POST | `/play-playlist` | `player.control` | `{ playlistId, platform? }`(清队列载入歌单) | `{ ok, message }` |
| POST | `/play-album` | `player.control` | `{ albumId, platform? }`(清队列载入专辑) | `{ ok, message }` |
| POST | `/playlist` | `player.queue` | `{ playlistId, platform? }`(追加整个歌单) | `{ message }` |
| POST | `/fm` | `player.control` | `{ platform? }`(私人 FM 模式) | `{ ok, message }` |
`/play` 与 `/add` 接受搜索文本,内部按 `platform` 调对应音源搜索并播放/入队第一个结果;`/play-song` 系列接受 `/api/music` 返回的完整 Song 对象。B站多P视频的 Song `id` 形如 `BVxxxx?p=2`(见 `/api/music/bilibili/parts`),传对应分P的 id 即播放该分P。
`/fm` 的平台为网易时,若调用者账户已绑定个人网易账号(见 `/api/me/music`),FM 曲目按**个人账号**的口味推荐;未绑定则使用机器人共享登录。
### 播放器控制
| 方法 | 路径 | 能力 | 请求体 | 返回 |
|------|------|------|--------|------|
| POST | `/pause` | `player.control` | — | `{ message }` |
| POST | `/resume` | `player.control` | — | `{ message }` |
| POST | `/next` | `player.control` | — | `{ message }` |
| POST | `/prev` | `player.control` | — | `{ message }` |
| POST | `/stop` | `player.control` | — | `{ message }` |
| POST | `/clear` | `player.queue` | — | `{ message }` |
| POST | `/volume` | `player.control` | `{ volume: 0-100 }` | `{ message }` |
| POST | `/mode` | `player.control` | `{ mode: "seq"|"loop"|"random"|"rloop" }` | `{ message }` |
| POST | `/seek` | `player.control` | `{ position: 秒 }` | `{ message, seekOffset }` |
| POST | `/play-at` | `player.control` | `{ index: 队列下标 }` | `{ message }`,越界 400 |
### 状态与队列
| 方法 | 路径 | 返回 |
|------|------|------|
| GET | `/queue` | `{ queue: QueuedSong[], status: BotStatus }` |
| GET | `/elapsed` | `{ elapsed: 42.5 }` |
| DELETE | `/queue/:index` | `{ message }`(移除指定下标,能力 `player.queue`) |
| GET | `/history?limit=50` | `{ history: [{ id, name, artist, album, coverUrl, platform, playedAt, requestedBy }] }` |
| GET | `/profile` | ProfileConfig |
| PUT | `/profile` | ProfileConfig(能力 `bot.manage`) |
ProfileConfig:`{ avatarEnabled, descriptionEnabled, nicknameEnabled, awayStatusEnabled, channelDescEnabled, nowPlayingMsgEnabled }`(机器人头像/昵称/频道描述等自动更新开关)。
---
## 音乐数据 /api/music
除特别标注外均为「已认证」;`platform` 为可选 query 参数。
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/search` | `q`(必填)、`platform`、`limit`(默认 20)、`offset`(默认 0) | `{ songs, albums, playlists }` |
| GET | `/search/all` | `q`(必填)、`limit` | 各音源合并的 `{ songs, albums, playlists }`(不含 spotify) |
| GET | `/song/:id` | `platform` | Song 对象,无则 404 |
| GET | `/album/:id` | `platform` | `{ songs: Song[] }` |
| GET | `/playlist/:id` | `platform` | `{ songs: Song[] }` |
| GET | `/playlist/:id/detail` | `platform` | `{ playlist: { id, name, description, coverUrl, songCount } }`(音源不支持时 501) |
| GET | `/lyrics/:id` | `platform` | `{ lyrics }` |
| GET | `/recommend/playlists` | `platform` | `{ playlists }` |
| GET | `/recommend/songs` | `platform` | `{ songs }`(每日推荐;非游客) |
| GET | `/personal/fm` | `platform` | `{ songs }`(私人 FM;非游客) |
| GET | `/user/playlists` | `platform` | `{ playlists }`(当前登录音源账号的歌单;非游客) |
| GET | `/bilibili/popular` | `limit`(默认 20) | `{ songs }` |
| GET | `/bilibili/parts` | `bvid`(BV 号或视频链接) | `{ bvid, title, coverUrl, artist, parts }`(无此视频 404) |
| GET | `/providers` | — | `{ enabled: Platform[], default: Platform }` |
| GET | `/quality` | — | `{ netease, qq, bilibili, local, kugou, spotify, jellyfin }` |
| POST | `/quality` | `{ quality, platform? }`(能力 `quality`;省略 platform 时对所有音源生效) | `{ success, quality }` |
### Jellyfin 音乐库
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/jellyfin/latest-albums` | `limit`(默认 12) | `{ albums }` |
| GET | `/jellyfin/most-played` | `limit`(默认 12) | `{ songs }` |
| GET | `/jellyfin/favorites` | `limit`(默认 100) | `{ songs }`(非游客) |
| GET | `/jellyfin/genres` | `limit`(默认 30) | `{ genres: [{ id, name }] }` |
| GET | `/jellyfin/genre/:id/songs` | `limit`(默认 100) | `{ songs }` |
### 本地音频上传
`POST /api/music/local/upload` — 能力 `player.queue`。请求体为**原始音频文件**(audio/* 或 video/*,≤500MB,非 multipart;文件名放 `x-filename` 请求头)。返回 `{ song }`;本地音频关闭时 403。
```bash
curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \
-H "Content-Type: application/octet-stream" \
--data-binary @theme.mp3 http://127.0.0.1:3000/api/music/local/upload
```
---
## 收藏 /api/favorites(非游客,仅本人数据)
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/` | — | `{ favorites: [{ id, platform, playlistId, name, coverUrl, songCount, createdAt }] }` |
| POST | `/` | `{ platform, playlistId, name, coverUrl?, songCount? }` | `{ success: true }`;已收藏 409 |
| GET | `/check` | `platform`、`playlistId` | `{ favorited: bool }` |
| DELETE | `/:id` | 收藏记录 id | `{ success: true }` |
---
## 保存的队列 /api/saved-queues(非游客;需在设置页开启「保存队列」)
所有权:私有为本人,`shared: true` 保存到共享桶;列表返回本人的+共享的;他人私有队列 404。
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/` | — | `{ queues: [{ id, ownerId, name, songCount, createdAt, updatedAt }] }` |
| POST | `/` | `{ botId, name, shared? }`(快照该 bot 当前队列,同名覆盖) | `{ queue }`;队列空 400 |
| POST | `/:id/load` | `{ botId, mode?: "replace"(默认)|"append" }` | `{ ok, loaded, mode }` |
| DELETE | `/:id` | — | `{ ok: true }` |
---
## 平台账号 /api/auth
| 方法 | 路径 | 权限 | 参数 | 返回 |
|------|------|------|------|------|
| GET | `/status` | 非游客 | `platform` | `{ platform, loggedIn, nickname?, avatarUrl? }` |
| POST | `/qrcode` | `platform.auth` | `{ platform }`(netease/qq/bilibili/kugou) | `{ qrUrl, qrImg?(base64 data URL), key }` |
| GET | `/qrcode/status` | 非游客 | `key`、`platform` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 自动持久化登录态 |
| POST | `/jellyfin/test` | `platform.auth` | `{ serverUrl?, authMode?, username?, password?, apiKey?, userId? }`(空字段回退已存配置) | `{ ok, serverName?, version?, error? }` |
| POST | `/sms/send` | `platform.auth` | `{ phone }`(网易手机号登录) | `{ success }` |
| POST | `/sms/verify` | `platform.auth` | `{ phone, code }` | `{ success }` |
| POST | `/cookie` | `platform.auth` | `{ platform, cookie }`(不支持 youtube/jellyfin) | `{ success: true }` |
## Spotify /api/spotify(配置 Spotify OAuth 后挂载)
| 方法 | 路径 | 权限 | 返回 |
|------|------|------|------|
| GET | `/login` | `platform.auth` | `{ url }`(accounts.spotify.com 授权页,浏览器打开) |
| GET | `/callback` | — | OAuth 回调,重定向回 WebUI(浏览器流程,脚本无需调用) |
| GET | `/status` | 非游客 | `{ authorized, backend, deviceName, binaryAvailable }` |
---
## 个人音乐账号 /api/me/music(非游客,仅本人数据)
绑定**自己的**网易账号,让 `POST /api/player/:botId/fm` 按个人口味推荐;cookie 只存服务端,任何接口都不会回传。与 `/api/auth` 的机器人共享登录互不影响。
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/netease/status` | — | `{ linked, loggedIn, nickname?, avatarUrl? }` |
| POST | `/netease/qrcode` | — | `{ qrUrl, qrImg?(base64 data URL), key }`(个人绑定专用二维码) |
| GET | `/netease/qrcode/status` | `key` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 后自动绑定到当前账户 |
| DELETE | `/netease` | — | `{ ok: true }`(解除绑定) |
---
## API 密钥管理 /api/keys(仅浏览器 session)
API Key **不能直接调用这些密钥管理端点**(403);游客 session 也被拒绝。浏览器登录后调用。管理员 Key 仍保留上文所述的用户管理权限。
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/` | `?all=1`(管理员可看全部,含 username) | `{ keys: [{ id, userId, username?, name, keyPrefix, createdAt, lastUsedAt }] }` |
| POST | `/` | `{ name: "1-64字符" }` | `201 { key: {...}, rawKey: "tsmb_…" }`(明文仅此一次);达上限 409 |
| DELETE | `/:id` | — | `{ success: true }`(仅本人;管理员可删任意) |
---
## 用户管理 /api/users(管理员)
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/` | — | `{ users: [{ id, username, createdAt, role }] }` |
| POST | `/` | `{ username, password(≥8位), role: "admin"|"member" }` | `201 { id, username, role }`;重名 409 |
| DELETE | `/:id` | — | `204`(级联删除其 session 与 API Key) |
| POST | `/:id/reset-password` | `{ newPassword }` | `204`(该用户的 API Key 全部失效,session 按上文密码变更规则处理) |
| PATCH | `/:id/role` | `{ role: "admin"|"member" }` | `204`(不能降级最后一个管理员) |
| GET | `/:id/permissions` | — | `{ capabilities: string[], bots: "all" | string[] }` |
| PUT | `/:id/permissions` | `{ capabilities, bots: "all"|string[] }` | `{ success: true }` |
## 操作审计 /api/audit(管理员)
| 方法 | 路径 | 参数 | 返回 |
|------|------|------|------|
| GET | `/` | `limit`(1-500,默认 100)、`offset`(默认 0) | `{ entries: [{ id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action }] }` |
action 取值:`admin.first_created`、`user.created`、`user.deleted`、`user.password_reset`、`user.password_changed`、`user.role_changed`、`user.permissions_changed`、`api_key.created`、`api_key.deleted`。
## 会话 /api/session(仅浏览器,API Key 不可用)
会话登录本身无法用 API Key 完成:`GET /needs-setup`、`POST /setup`、`POST /login`、`POST /guest`、`POST /logout`、`GET /me`、`POST /change-password` 均基于 cookie。`/login` 有每 IP 每分钟 5 次、`/setup` 3 次的限流。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
# PR integration and v1.15.0 implementation plan
> For agentic workers: use the parallel implementation and independent review tools. Steps use checkbox syntax.
**Goal:** Fix the reviewed defects in PRs #170, #173, #174 and #175, merge the tested result into main, and publish the next release.
**Architecture:** Retain each original PR head in merge history. Fix independent modules in parallel with exclusive file ownership. Push the final integration only after source tests, builds and independent review pass.
**Tech Stack:** Node.js, TypeScript, Vitest, Express, Vue, TeamSpeak client SDK, GitHub Actions.
**Spec:** User requests in this chat: review every open PR, merge safe fixes/enhancements into main and test; then merge and publish a new version; then explicitly fix the reported defects.
## Global constraints
- Preserve inherited work; checkout is clean at 87fca6d8b7b770e1e01f8891059c99d53705cc08.
- Do not introduce new dependencies or change music-provider authorization.
- Preserve API key role/capability inheritance. Password rotation must revoke the user's keys.
- Match repository release convention: application package version remains 0.1.0; release tags identify shipped versions.
- Source tests exclude generated dist/** and web/dist/**.
- Do not force-push or rewrite original contributor history.
## Review focus
- A pending new playback request must survive an older EOF continuation.
- Artist playback and single-song playback must serialize their queue mutations.
- Transient QQ singer/album failures must not poison successful catalog caching.
- Channel movement and reconnect must use the correct session and clear through the same permission path.
- Credential revocation must audit the actual key owner and ordinary password changes must revoke keys.
### Task 1: Integrate original PR history
Files: src/bot/instance.test.ts (resolve #170 overlap by preserving both test suites).
- [x] Verify open PR heads remain the audited SHAs.
- [x] Create a release integration branch from origin/main.
- [x] Merge #173, #174, #175 and #170 with merge commits; resolve the instance test conflict by retaining both independent additions.
### Task 2: Correct artist playback and QQ catalogs
Owner files: src/music/qq.ts, src/music/qq.test.ts, src/web/api/player.ts, src/web/api/play-artist.test.ts.
- [x] Port the four review probes from ../.pr-review-20261003/175/review/review-artist-regressions.test.ts into repository tests.
- [x] Run npm test -- src/music/qq.test.ts src/web/api/play-artist.test.ts and observe the known failures.
- [x] Use bot.runExclusive for the stop/queue mutation/play sequence. Preserve permission middleware.
- [x] Return a failure sentinel for failed singer lookup or malformed/nonzero album-search results; do not cache degradation.
- [x] Scan albums until the 500-song ceiling or complete catalog; preserve hasMore/total correctness, avoid the silent 50-album limit.
- [x] Re-run focused tests and report changed files and result.
### Task 3: Correct TS6 profile lifecycle
Owner files: src/bot/profile.ts, src/bot/profile.test.ts, src/ts-protocol/http-query.ts, optionally src/ts-protocol/client.ts and a related focused protocol test if checked self updates need it.
- [x] Port the three reviewer probes from ../.pr-review-20261003/174/src/bot/review-174.test.ts into profile tests.
- [x] Confirm they fail before production changes.
- [x] Clear old channel descriptions through checked HTTP channelEdit for TS6 and preserve TS3's working behavior.
- [x] Fence client-list resolution and post-write remembered-channel state by generation/connection identity.
- [x] Use a checked full-client self clientupdate that reports permission failures; never update HTTP ServerQuery self.
- [x] Update old channel-description mocks to reflect checked TS3 writes without weakening assertions.
- [x] Run profile/protocol tests and typecheck; report changes.
### Task 4: Correct API-key lifecycle and documentation
Owner files: src/data/api-keys.ts, src/data/api-keys.test.ts, src/web/api/api-keys.ts, src/web/api/api-keys.test.ts, src/web/api/session.ts, src/web/api/session.test.ts, src/web/server.ts, docs/API.md.
- [x] Test administrator revocation auditing the member owner and ordinary password changes invalidating old keys.
- [x] Run the tests and observe the failures.
- [x] Snapshot the key owner before deletion and use that owner in the audit target fields.
- [x] Thread the API key store into the browser session router and revoke all keys after a successful self-service password change; preserve the active browser session convention.
- [x] Keep documented administrator REST authority. Qualify the no-self-replication claim to direct /api/keys management; do not remove administrator /api/users functionality silently.
- [x] Add Content-Type: application/octet-stream to the curl upload example.
- [x] Run the focused auth/session/key suites; report changes.
### Task 5: Correct EOF/recovery ordering
Owner files: src/bot/instance.ts, src/bot/instance.test.ts. Do not change the artist route owned by Task 2.
- [x] Turn the independent actual-handler probe into repository tests using the existing setupPlayerEvents fixture; avoid runtime source transpilation.
- [x] Confirm normal NetEase/Bilibili EOF can currently advance a pending replacement.
- [x] Fence every delayed fallback by the ending song and playback session, including failed recovery; preserve immediate ordinary EOF ordering where practical.
- [x] Verify stop, skip, restart of the same queue song, pause, null URL and failed lookup do not clobber a newer playback session.
- [x] Run instance/player/Bilibili tests and report results.
### Task 6: Review, verify and release
Owner files: README.md changelog; release notes kept outside the repository for gh --notes-file.
- [x] Independently review every correction and the integrated changes; resolve substantive findings, including paused recovery, malformed QQ rows, and artist UI permissions/response ordering.
- [x] Run npm test (generated outputs excluded by vitest.config.ts) and npm run build sequentially; repeat affected verification after final review fixes.
- [x] Update the README changelog and prepare release notes with contributor credits, changes, migration notes and verified test results.
- [ ] Confirm main has not moved, integrate the tested branch and push main without force.
- [ ] Verify all four GitHub PRs show merged and point at the integrated history.
- [ ] Create and push v1.15.0 (or the user's chosen version), create the GitHub release, and inspect the Docker publish workflow to completion.
- [ ] Report the release URL, test totals and Docker publishing result. State that live TeamSpeak/music-provider integration was not exercised.
## Final local evidence (2026-10-03)
- All four audited PR heads were unchanged on GitHub before publishing.
- Every correction passed independent review; no malicious behavior was found.
- After the final artist UI corrections, `npm test` passed 79 source test files and all 1283 tests.
- `npm run build` passed backend TypeScript, Vue type checking and production bundling.
- `npm run check:native` passed. Compiled Vue component probes verified artist permission combinations and late-response ordering.
- Live TeamSpeak servers and music-provider playback were not exercised. Publishing evidence is recorded in the GitHub release and its Docker workflow.
@@ -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).
+5
View File
@@ -3,10 +3,15 @@
"version": "0.1.0",
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module",
"engines": {
"node": "^22.12.0 || >=24.0.0"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && npm run build:web",
"build:web": "cd web && npm run build",
"check:native": "node scripts/check-native.mjs",
"prestart": "node scripts/check-native.mjs",
"start": "node dist/index.js",
"play": "node dist/index.js",
"test": "vitest run",
+591
View File
@@ -0,0 +1,591 @@
diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts
index b2adb3d..ecc2202 100644
--- a/src/music/netease.test.ts
+++ b/src/music/netease.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { parseLyrics } from "./netease.js";
+import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
+
+ it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
+ const raw = [
+ {
+ id: 42,
+ name: "Album A",
+ picUrl: "https://x/p.jpg",
+ artists: [{ name: "Artist X" }, { name: "Featured Y" }],
+ size: 12,
+ },
+ {
+ id: 99,
+ name: "Album B",
+ picUrl: "",
+ artists: [],
+ },
+ ];
+ expect(mapNeteaseAlbums(raw)).toEqual([
+ { id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
+ { id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
+ ]);
+ });
+
+ it("mapNeteaseAlbums returns [] for empty/null input", () => {
+ expect(mapNeteaseAlbums([])).toEqual([]);
+ expect(mapNeteaseAlbums(null as any)).toEqual([]);
+ expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
+ });
});
diff --git a/src/music/netease.ts b/src/music/netease.ts
index 8aaeab6..4a863db 100644
--- a/src/music/netease.ts
+++ b/src/music/netease.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
+export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => ({
+ id: String(a.id),
+ name: a.name ?? "",
+ artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
+ coverUrl: a.picUrl ?? "",
+ songCount: a.size ?? 0,
+ platform: "netease",
+ }));
+}
+
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
- const [songRes, playlistRes] = await Promise.all([
+ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider {
...this.cookieParams,
},
}),
+ this.api.get("/cloudsearch", {
+ params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
+ }),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
- return { songs, playlists, albums: [] };
+ const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
+
+ return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts
new file mode 100644
index 0000000..4f606cf
--- /dev/null
+++ b/src/music/qq.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from "vitest";
+import { mapQqAlbums } from "./qq.js";
+
+describe("QQ adapter", () => {
+ it("mapQqAlbums maps albumMID-style raw entries", () => {
+ const raw = [
+ {
+ albumMID: "abc",
+ albumName: "Aero",
+ singerName: "Singer A",
+ },
+ {
+ albumMID: "xyz",
+ albumName: "Beta",
+ singer: [{ name: "Singer B" }, { name: "Singer C" }],
+ },
+ ];
+ const out = mapQqAlbums(raw);
+ expect(out).toHaveLength(2);
+ expect(out[0]).toMatchObject({
+ id: "abc",
+ name: "Aero",
+ artist: "Singer A",
+ platform: "qq",
+ });
+ expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
+ expect(out[1].artist).toBe("Singer B / Singer C");
+ expect(out[1].coverUrl).toContain("xyz");
+ });
+
+ it("mapQqAlbums returns [] for empty/null input", () => {
+ expect(mapQqAlbums([])).toEqual([]);
+ expect(mapQqAlbums(null as any)).toEqual([]);
+ expect(mapQqAlbums(undefined as any)).toEqual([]);
+ });
+
+ it("mapQqAlbums falls back to albumPic when no albumMID", () => {
+ const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
+ const out = mapQqAlbums(raw);
+ expect(out[0].coverUrl).toBe("https://x/p.jpg");
+ expect(out[0].id).toBe("");
+ });
+});
diff --git a/src/music/qq.ts b/src/music/qq.ts
index 9c0360d..1e7a8ae 100644
--- a/src/music/qq.ts
+++ b/src/music/qq.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -27,6 +28,26 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
+export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => {
+ const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
+ const artist = a.singerName
+ ?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
+ const coverUrl = id
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
+ : (a.albumPic ?? "");
+ return {
+ id,
+ name: a.albumName ?? a.title ?? "",
+ artist,
+ coverUrl,
+ songCount: a.song_count ?? a.songCount ?? 0,
+ platform: "qq" as const,
+ };
+ });
+}
+
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider {
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
- param: {
- searchid: "1",
- query,
- num_per_page: Math.min(limit, 50),
- },
+ param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
+ },
+ req_album: {
+ module: "music.search.SearchCgiService",
+ method: "DoSearchForQQMusicDesktop",
+ param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq",
}));
- return { songs, playlists: [], albums: [] };
+ const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
+ const albums = mapQqAlbums(albumList);
+
+ return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index b08f9a2..edf9c04 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
- ...(neteaseResult.status === "fulfilled"
- ? neteaseResult.value.songs
- : []),
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
+ const albums = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
+ ];
+ const playlists = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
+ ];
- res.json({ songs });
+ res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index a9af600..4f0930b 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -313,6 +313,78 @@ export function createPlayerRouter(
}
});
+ // Play an album by ID — mirrors play-playlist but calls getAlbumSongs
+ router.post("/:botId/play-album", async (req, res) => {
+ try {
+ const bot = (req as any).bot;
+ const { albumId, platform } = req.body;
+ const provider = bot.getProviderFor(
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
+ ? platform
+ : "netease"
+ );
+
+ // Stop current playback
+ bot.getPlayer().stop();
+ bot.getPlayer().resetFailures();
+
+ const songs = await provider.getAlbumSongs(albumId);
+ if (songs.length === 0) {
+ res.json({ message: "Album is empty" });
+ return;
+ }
+
+ // QQ-specific optimization: batch-resolve playable IDs to avoid
+ // wasting retries on region/copyright-restricted tracks.
+ let queueable: { id: string }[] = songs;
+ const totalCount = songs.length;
+ const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
+ if (typeof qqLike.getPlayableSongIds === "function") {
+ const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
+ if (playable !== null) {
+ queueable = songs.filter((s: { id: string }) => playable.has(s.id));
+ }
+ }
+ if (queueable.length === 0) {
+ res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
+ return;
+ }
+
+ const queue = bot.getQueueManager();
+ queue.clear();
+ for (const song of queueable) {
+ queue.add({ ...song, platform: provider.platform });
+ }
+
+ const mode = queue.getMode();
+ let first;
+ if (mode === "random" || mode === "rloop") {
+ const idx = Math.floor(Math.random() * queue.size());
+ first = queue.playAt(idx);
+ } else {
+ first = queue.play();
+ }
+
+ let started = first ? await bot.resolveAndPlay(first) : false;
+ if (first && !started) {
+ started = await bot.playNext(20);
+ }
+
+ const playing = queue.current();
+ const loadedMsg = queueable.length < totalCount
+ ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
+ : `已加载 ${queueable.length} 首`;
+ if (started && playing) {
+ res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
+ } else {
+ res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
+ }
+ } catch (err) {
+ logger.error({ err }, "play-album failed");
+ res.status(500).json({ error: (err as Error).message });
+ }
+ });
+
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index cc060f5..d62afcd 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -22,6 +22,13 @@ const router = createRouter({
path: '/playlist/:id',
name: 'playlist',
component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'playlist' },
+ },
+ {
+ path: '/album/:id',
+ name: 'album',
+ component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'album' },
},
{
path: '/lyrics',
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index 9bc817d..083262c 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
+ async playAlbum(albumId: string, platform = 'netease') {
+ if (!this.activeBotId) return;
+ const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
+ if (res.data?.message) {
+ this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
+ }
+ this._setTiming(this.activeBotId, { serverElapsed: 0 });
+ this._syncAfterAction();
+ },
+
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value
diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue
index d5c9f8d..d00101d 100644
--- a/web/src/views/Playlist.vue
+++ b/web/src/views/Playlist.vue
@@ -38,7 +38,7 @@
</div>
</template>
- <div v-else class="loading">歌单不存在或加载失败</div>
+ <div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div>
</template>
@@ -64,6 +64,8 @@ interface PlaylistDetail {
songCount: number;
}
+const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
+
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
@@ -71,20 +73,32 @@ const loading = ref(true);
async function playAll() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
- await store.playPlaylist(id, platform);
+ if (kind === 'album') {
+ await store.playAlbum(id, platform);
+ } else {
+ await store.playPlaylist(id, platform);
+ }
}
onMounted(async () => {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
+ const detailUrl = kind === 'album'
+ ? `/api/music/album/${id}/detail`
+ : `/api/music/playlist/${id}/detail`;
+ const songsUrl = kind === 'album'
+ ? `/api/music/album/${id}`
+ : `/api/music/playlist/${id}`;
+
// allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than
- // the "歌单不存在" empty state.
+ // the "不存在" empty state. For albums, detail always 404s — that
+ // is intentional; the fallback stub below handles it.
const [detailRes, songsRes] = await Promise.allSettled([
- axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
- axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
+ axios.get(detailUrl, { params: { platform } }),
+ axios.get(songsUrl, { params: { platform } }),
]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
@@ -96,7 +110,7 @@ onMounted(async () => {
// Fall back to a stub built from the route + first song's cover.
playlist.value = {
id,
- name: '歌单',
+ name: kind === 'album' ? '专辑' : '歌单',
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
@@ -104,7 +118,7 @@ onMounted(async () => {
} else {
playlist.value = null;
if (detailRes.status === 'rejected') {
- console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
+ console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
}
}
songs.value = songList;
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
index 0536e36..8822277 100644
--- a/web/src/views/Search.vue
+++ b/web/src/views/Search.vue
@@ -20,22 +20,54 @@
<div v-if="loading" class="loading">搜索中...</div>
- <div v-else-if="results.length > 0" class="results">
- <SongCard
- v-for="(song, i) in results"
- :key="`${song.platform}-${song.id}`"
- :song="song"
- :index="i + 1"
- :active="store.currentSong?.id === song.id"
- @play="store.playSong(song)"
- @playNext="store.playNextSong(song)"
- @add="store.addSong(song)"
- />
- </div>
+ <template v-else-if="songs.length || albums.length || playlists.length">
+ <section v-if="albums.length" class="result-section">
+ <h2 class="section-title">专辑</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="al in albums"
+ :key="`${al.platform}-${al.id}`"
+ :to="`/album/${al.id}?platform=${al.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ al.name }}</div>
+ <div class="card-sub">{{ al.artist }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="playlists.length" class="result-section">
+ <h2 class="section-title">歌单</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="pl in playlists"
+ :key="`${pl.platform}-${pl.id}`"
+ :to="`/playlist/${pl.id}?platform=${pl.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ pl.name }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="songs.length" class="result-section">
+ <h2 class="section-title">单曲</h2>
+ <SongCard
+ v-for="(song, i) in songs"
+ :key="`${song.platform}-${song.id}`"
+ :song="song"
+ :index="i + 1"
+ :active="store.currentSong?.id === song.id"
+ @play="store.playSong(song)"
+ @playNext="store.playNextSong(song)"
+ @add="store.addSong(song)"
+ />
+ </section>
+ </template>
- <div v-else-if="searched" class="empty">
- 未找到相关结果
- </div>
+ <div v-else-if="searched" class="empty">未找到相关结果</div>
</div>
</template>
@@ -45,15 +77,21 @@ import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
+import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
+import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const query = ref((route.query.q as string) || '');
-import { Song } from '../stores/player.js';
-const results = ref<Song[]>([]);
+interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
+interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
+
+const songs = ref<Song[]>([]);
+const albums = ref<Album[]>([]);
+const playlists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
@@ -62,12 +100,12 @@ async function doSearch() {
loading.value = true;
searched.value = true;
try {
- const res = await axios.get('/api/music/search/all', {
- params: { q: query.value },
- });
- results.value = res.data.songs;
+ const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
+ songs.value = res.data.songs ?? [];
+ albums.value = res.data.albums ?? [];
+ playlists.value = res.data.playlists ?? [];
} catch {
- results.value = [];
+ songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
@@ -141,4 +179,23 @@ onMounted(() => {
flex-direction: column;
gap: 2px;
}
+
+.result-section {
+ margin-bottom: 32px;
+ .section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
+}
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 16px;
+}
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ text-decoration: none;
+ color: inherit;
+ .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
+ .card-sub { font-size: 12px; opacity: 0.6; }
+}
</style>
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* Preflight: can THIS Node build actually load the native modules that are
* sitting in node_modules?
*
* A compiled addon is tied to one Node ABI (process.versions.modules:
* Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major,
* launch under another, and the bot dies deep inside startup with a
* `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it.
* This script turns that into one actionable sentence, before anything starts.
*
* Exit code:
* 0 every required native module loads (or is simply not installed yet —
* that is npm install's problem, not an ABI problem)
* 1 a required native module definitively fails to load; the bot could not
* have started anyway, so there is no false-positive risk here.
*
* Usage: node scripts/check-native.mjs
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createLineWriter } from "./lib/console-log.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules");
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
const NODE_ABI = process.versions.modules;
/** Only the modules the bot cannot start without. ffmpeg-static is optional
* (a system ffmpeg on PATH works too), so it is not checked here. */
const REQUIRED = ["@discordjs/opus", "better-sqlite3"];
function pkgDirOf(spec) {
return join(NODE_MODULES, ...spec.split("/"));
}
function summarizeError(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
return (interesting || lines[0] || "unknown error").slice(0, 300);
}
/**
* The snippet that actually forces each package's addon to be dlopen()ed.
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor,
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI
* binary. Opening an in-memory database is the cheapest way to really load it.
*/
const PROBE_EXPR = {
"@discordjs/opus": "require('@discordjs/opus')",
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
};
/**
* Load-probe in a throwaway child process. Child process on purpose: requiring
* an addon in this process would keep the DLL mapped, and Windows then refuses
* to let setup.bat replace the file we just told the user to replace.
*/
function probeRequire(spec) {
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
try {
execFileSync(process.execPath, ["-e", expr], {
cwd: ROOT,
stdio: "pipe",
timeout: 120000,
windowsHide: true,
});
return { ok: true };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
// requires NODE_MODULE_VERSION 127..." -> first number is the build target.
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
return {
ok: false,
abiMismatch: abis.length >= 2,
compiledAbi: abis.length >= 2 ? abis[0] : null,
error: summarizeError(text),
};
}
}
function readStamp() {
try {
return JSON.parse(readFileSync(STAMP_FILE, "utf8"));
} catch {
return null;
}
}
const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh";
const broken = [];
for (const spec of REQUIRED) {
if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job
const probe = probeRequire(spec);
if (!probe.ok) broken.push({ spec, ...probe });
}
function report() {
const stamp = readStamp();
const mismatch = broken.find((b) => b.abiMismatch);
// Never let a failed console write become an uncaught error and replace this
// report with a stack trace - the console that cannot print the Chinese half
// of these lines is exactly the one a user needs the English half from.
// See scripts/lib/console-log.mjs and issue #152.
const out = createLineWriter(process.stderr);
out("");
out("============================================================");
if (mismatch) {
out(" [ERROR] 原生模块与当前 Node 版本不匹配");
out(" Native modules do not match this Node version");
} else {
out(" [ERROR] 原生模块无法加载 / native module failed to load");
}
out("============================================================");
out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`);
if (stamp && stamp.abi) {
out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`);
out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`);
}
out("");
for (const b of broken) {
if (b.abiMismatch) {
out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`);
out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`);
out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`);
} else {
out(` x ${b.spec}: ${b.error}`);
}
}
out("");
out(" 怎么修 / How to fix:");
out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`);
out(" (它会自动为当前 Node 版本重新安装原生模块)");
out(" (setup now repairs the native modules for whatever Node you run)");
out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version");
out(" you installed with, then start again.");
out("============================================================");
out("");
}
if (broken.length > 0) {
report();
// exitCode rather than exit(): lets the message flush when stderr is piped.
process.exitCode = 1;
}
+9 -2
View File
@@ -4,7 +4,7 @@
# ==========================================
# --- Stage 1: Build backend + frontend ---
FROM node:20-slim AS builder
FROM node:22-slim AS builder
# Install build tools for native modules (opus, better-sqlite3)
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -30,7 +30,7 @@ RUN npm run build
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# --- Stage 2: Production image ---
FROM node:20-slim
FROM node:22-slim
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
@@ -46,6 +46,13 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# package.json declares a `prestart` preflight, so `npm start` inside the
# container needs this file. The image's own CMD calls node directly and never
# goes through npm, but an interactive `docker exec ... npm start` would
# otherwise die on a missing script rather than starting the bot.
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
# ...and the module it imports for crash-proof logging.
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
+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;
}
+67 -25
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Installer (Linux, systemd)
# - Installs system packages and Node.js 22 LTS
# - Runs scripts/setup.sh to install dependencies, verify native binaries and build
# - Copies the build to /opt/tsmusicbot and registers a systemd service (auto-start on boot)
#
# Only want to build and run it yourself (no Node install, no service)?
# Use scripts/setup.sh instead — see README「Linux 安装脚本」.
#
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
@@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
# Node LTS line to install when a supported Node is missing. Keep in sync with
# package.json "engines" and the floor check in setup.sh (#152).
NODE_LTS_MAJOR=22
# Verify we're in a valid project directory
if [ ! -f "$PROJECT_DIR/package.json" ]; then
@@ -28,42 +41,66 @@ else
exit 1
fi
echo "[1/6] Installing system dependencies..."
# Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest).
node_supported() {
command -v node &> /dev/null &&
node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'
}
echo "[1/5] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl build-essential python3
sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg
;;
centos|rhel|fedora)
centos|rhel|fedora|rocky|almalinux)
sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
sudo pacman -S --noconfirm curl base-devel python
sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg
;;
*)
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually."
;;
esac
echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/6] Installing dependencies..."
cd "$PROJECT_DIR"
npm install
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
(cd "$PROJECT_DIR/web" && npm install)
echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..."
if node_supported; then
echo "Node.js $(node -v) already installed"
else
case $OS in
ubuntu|debian)
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
sudo apt-get install -y -qq nodejs
;;
centos|rhel|fedora|rocky|almalinux)
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
sudo yum install -y nodejs
;;
arch|manjaro)
sudo pacman -S --noconfirm --needed nodejs npm
;;
esac
if ! node_supported; then
echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)."
echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script."
exit 1
fi
echo "Node.js $(node -v) installed"
fi
echo "[4/6] Building project..."
npm run build
echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
bash "$SCRIPT_DIR/setup.sh"
echo "[5/6] Copying to $INSTALL_DIR..."
echo "[4/5] Copying to $INSTALL_DIR..."
# Stop a running copy before replacing its files (re-install / upgrade).
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
sudo systemctl stop "$SERVICE_NAME"
fi
sudo mkdir -p "$INSTALL_DIR"
# Replace build output wholesale so files removed upstream don't linger.
# data/ (config, database, cookies) is never touched.
sudo rm -rf "$INSTALL_DIR/dist" "$INSTALL_DIR/node_modules" "$INSTALL_DIR/web/dist"
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
@@ -72,13 +109,18 @@ if [ -d "$PROJECT_DIR/web/dist" ]; then
sudo mkdir -p "$INSTALL_DIR/web"
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
fi
# yt-dlp is looked up in bin/ next to dist/ before falling back to PATH
if [ -d "$PROJECT_DIR/bin" ]; then
sudo cp -r "$PROJECT_DIR/bin" "$INSTALL_DIR/"
fi
# Copy scripts for future use
sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory
sudo mkdir -p "$INSTALL_DIR/data"
echo "[6/6] Creating systemd service..."
echo "[5/5] Creating systemd service..."
NODE_BIN="$(command -v node)"
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,7 +130,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -99,15 +141,15 @@ EOL
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
sudo systemctl restart ${SERVICE_NAME}
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot installed and running! ║"
echo "║ ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ ║"
echo "║ Commands: ║"
echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
+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.
:: ============================================================
+44 -7
View File
@@ -21,12 +21,34 @@ echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
echo "[OK] Node.js $(node -v)"
# Newest Node major this project is regularly tested against. Anything above
# still works, it just may have no prebuilt addons and fall back to a source build.
TESTED_NODE_MAJOR=22
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
# The floor is not just a major version, so let node decide. Node 20 was dropped:
# better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
# Node 20 install needed Python and a C++ toolchain just to get off the ground
# (issue #152). The odd majors (21 / 23) are excluded by better-sqlite3 and vitest.
# Keep in sync with package.json "engines".
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
echo " https://nodejs.org/ | https://nodejs.cn/"
exit 1
fi
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 22)."
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
fi
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
@@ -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
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { PassThrough } from "node:stream";
import { collectFfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
async function finish(stream: PassThrough): Promise<void> {
const ended = new Promise<void>((resolve) => stream.once("end", resolve));
stream.end();
await ended;
}
describe("bounded FFmpeg diagnostics", () => {
for (const [label, line, expected] of [
["an apostrophe in the real FFmpeg URL error format", "Error opening input file http://127.0.0.1:9/audio?filename=artist's-song&api_key=quoted-secret.", "Error opening input file [URL omitted]"],
["a space in URL userinfo", 'Error opening input file https://user:space secret@cdn.example/audio?token=space-secret.', "Error opening input file [URL omitted]"],
["multiple URLs", 'Error opening inputs https://cdn.example/a?filename=artist\'s-song&key=first-secret and https://cdn.example/b?token=second-secret', "Error opening inputs [URL omitted]"],
["quotes and spaces in a request target", "GET /audio?filename=artist's song&api_key=request-secret HTTP/1.1", "GET /audio?[query omitted]"],
["an apostrophe in a Bearer value", "Token rejected Bearer prefix'quoted bearer-secret", "Token rejected Bearer [omitted]"],
]) {
it(`omits the entire sensitive suffix after ${label}`, async () => {
const stream = new PassThrough();
const diagnostics = collectFfmpegDiagnostics(stream);
stream.write(line + "\n");
await finish(stream);
expect(diagnostics.getTail()).toBe(expected);
});
}
for (const splitDelimiter of [false, true]) {
it(`omits folded authentication values with ${splitDelimiter ? "chunk-split" : "intact"} CRLF`, async () => {
const stream = new PassThrough();
const diagnostics = collectFfmpegDiagnostics(stream);
stream.write(`Authorization: Basic header-secret\r${splitDelimiter ? "" : "\n"}`);
if (splitDelimiter) stream.write("\n");
stream.write(" continuation-secret\r\nHTTP error 401\r\n");
await finish(stream);
expect(diagnostics.getTail()).toContain("HTTP error 401");
expect(diagnostics.getTail()).not.toContain("header-secret");
expect(diagnostics.getTail()).not.toContain("continuation-secret");
});
}
it("redacts URLs and headers split across arbitrary byte and UTF-8 boundaries", async () => {
const stream = new PassThrough();
const diagnostics = collectFfmpegDiagnostics(stream);
const payload = Buffer.from("解码失败 https://user:user-secret@cdn.example/audio?token=query-secret#fragment-secret\rCookie: cookie-secret\nAuthorization: Bearer bearer-secret\nGET /audio?token=request-secret HTTP/1.1\nfinal error");
for (const byte of payload) stream.write(Buffer.from([byte]));
await finish(stream);
expect(diagnostics.getTail()).toContain("解码失败");
expect(diagnostics.getTail()).toContain("final error");
for (const secret of ["user-secret", "query-secret", "fragment-secret", "cookie-secret", "bearer-secret", "request-secret"]) {
expect(diagnostics.getTail()).not.toContain(secret);
}
});
it("omits oversized raw lines without retaining an unsafe credential suffix", async () => {
const stream = new PassThrough();
const diagnostics = collectFfmpegDiagnostics(stream);
stream.write("Cookie: " + "x".repeat(100000));
stream.write("oversized-secret\r\n folded-oversized-secret\r\nHTTP error 403\n");
await new Promise<void>((resolve) => setImmediate(resolve));
expect(diagnostics.getTail()).toContain("HTTP error 403");
expect(diagnostics.getTail()).not.toContain("oversized-secret");
stream.write("decoder warning\n".repeat(10000));
stream.write("last useful error\n");
await finish(stream);
expect(diagnostics.getTail().length).toBeLessThanOrEqual(4096);
expect(diagnostics.getTail()).toContain("last useful error");
expect(diagnostics.getTail()).not.toContain("oversized-secret");
});
it("withholds an incomplete credential line until it can be safely sanitized", async () => {
const stream = new PassThrough();
const diagnostics = collectFfmpegDiagnostics(stream);
stream.write("decoder warning\nhttps://user:partial-secret@");
await new Promise<void>((resolve) => setImmediate(resolve));
expect(diagnostics.getTail()).toContain("decoder warning");
expect(diagnostics.getTail()).not.toContain("partial-secret");
stream.write("cdn.example/audio?token=last-secret");
await finish(stream);
expect(diagnostics.getTail()).not.toContain("partial-secret");
expect(diagnostics.getTail()).not.toContain("last-secret");
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
const MAX_LINE_CHARS = 2048;
const MAX_TAIL_CHARS = 4096;
export interface FfmpegDiagnostics {
getTail(): string;
}
/**
* Drain independently of the PCM pipe: unread stderr can block FFmpeg even
* when stdout is being consumed. Keep only complete, sanitized lines. Never
* retain a suffix of an oversized raw line: it may have lost its URL/header
* prefix and would no longer be possible to redact safely.
*/
export function collectFfmpegDiagnostics(stderr: Readable | null): FfmpegDiagnostics {
let tail = "";
let pending = "";
let discardLine = false;
let suppressHeaderContinuation = false;
let previousCR = false;
const decoder = new StringDecoder("utf8");
const append = (text: string): void => {
if (text) previousCR = false;
if (discardLine) return;
if (pending.length + text.length > MAX_LINE_CHARS) {
pending = "";
discardLine = true;
return;
}
pending += text;
};
const finishLine = (): void => {
if (discardLine) {
tail = (tail + "[oversized diagnostic line omitted]\n").slice(-MAX_TAIL_CHARS);
// An omitted line may be an authentication header. Omit folded values.
suppressHeaderContinuation = true;
} else if (pending) {
const line = pending
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
const authHeader = /\b(?:cookie|set-cookie|authorization|proxy-authorization)\s*[:=]/i.test(line);
if (authHeader || (suppressHeaderContinuation && /^\s/.test(line))) {
tail = (tail + "[authentication header omitted]\n").slice(-MAX_TAIL_CHARS);
suppressHeaderContinuation = true;
} else {
suppressHeaderContinuation = false;
const sanitized = line
// URLs and credentials can contain quotes or spaces. Keep the error
// prefix only; guessing a closing delimiter could expose a suffix.
.replace(/\b[a-z][a-z\d+.-]*:\/\/[\s\S]*/i, "[URL omitted]")
// FFmpeg can also print a request target without the scheme/host.
.replace(/\?[\s\S]*/, "?[query omitted]")
.replace(/\bBearer\s+[\s\S]*/i, "Bearer [omitted]");
tail = (tail + sanitized + "\n").slice(-MAX_TAIL_CHARS);
}
} else {
suppressHeaderContinuation = false;
}
pending = "";
discardLine = false;
};
const consume = (text: string): void => {
const separators = /[\r\n]/g;
let start = 0;
for (let match = separators.exec(text); match; match = separators.exec(text)) {
append(text.slice(start, match.index));
if (match[0] === "\n" && previousCR) {
previousCR = false;
} else {
finishLine();
previousCR = match[0] === "\r";
}
start = match.index + 1;
}
append(text.slice(start));
};
stderr?.on("data", (chunk: Buffer) => consume(decoder.write(chunk)));
stderr?.on("end", () => {
consume(decoder.end());
finishLine();
});
// Resume explicitly as attaching a listener does not resume an already
// paused Readable. No player pause/backpressure operation touches stderr.
stderr?.resume();
return { getTail: () => tail.trimEnd() };
}
+342 -3
View File
@@ -2,10 +2,20 @@ import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { Readable, PassThrough } from "node:stream";
import { EventEmitter } from "node:events";
import { spawn, type ChildProcess } from "node:child_process";
import pino from "pino";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js";
import type { Logger } from "../logger.js";
// Only replace process creation in the regression cases below. Their pipes,
// write callbacks and backpressure are real OS resources, rather than mocks.
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: vi.fn(actual.spawn) };
});
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
if (idx === -1) return "";
@@ -36,6 +46,12 @@ describe("buildFfmpegArgs", () => {
expect(args).not.toContain("-headers");
});
it("disables periodic progress stats for both network and file inputs", () => {
for (const input of ["https://example.com/song.mp3", "C:/temp/song.audio"]) {
expect(buildFfmpegArgs(input, 0)).toContain("-nostats");
}
});
it("includes resilient reconnect flags for all URLs", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).toContain("-reconnect");
@@ -55,13 +71,21 @@ 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("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
const ssIdx = args.indexOf("-ss");
expect(args[ssIdx + 1]).toBe("3600");
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
});
it("does not insert -ss when seekSeconds is 0", () => {
@@ -216,6 +240,321 @@ const silentLogger = {
},
} as unknown as Logger;
describe("AudioPlayer FFmpeg stderr handling", () => {
const producer = `
const pressure = 'decoder diagnostic\\n'.repeat(180000);
process.stderr.write(pressure, () => {
process.stderr.write('HTTP error 403 for https://user:password@cdn.example/audio?token=signed-secret#fragment-secret\\n');
process.stderr.write('Cookie: cookie-secret\\nAuthorization: Bearer bearer-secret\\n');
process.stderr.write('final decoder failure', () => {
process.stdout.write(Buffer.alloc(7680), () => process.exit(1));
});
});
`;
function recordedLogger() {
const records: Array<{ level: string; fields: Record<string, unknown>; message: string }> = [];
const capture = (level: string) => (fields: Record<string, unknown>, message: string) => {
records.push({ level, fields, message });
};
return {
records,
logger: { ...silentLogger, info: capture("info"), warn: capture("warn") } as unknown as Logger,
};
}
async function pipeProducer(script: string) {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
let child!: ChildProcess;
let requestedArgs: readonly string[] = [];
let closed!: Promise<number | null>;
vi.mocked(spawn).mockImplementationOnce((_command, args, options) => {
requestedArgs = args ?? [];
child = actual.spawn(process.execPath, ["-e", script], options);
closed = new Promise((resolve) => child.once("close", resolve));
return child;
});
return {
get child() { return child; },
get args() { return requestedArgs; },
get closed() { return closed; },
};
}
for (const path of ["URL", "temp file"] as const) {
it(`drains the ${path} child stderr so a large diagnostic write cannot block PCM output`, async () => {
const { records, logger } = recordedLogger();
const producerProcess = await pipeProducer(producer);
const player = new AudioPlayer(logger);
let frameCount = 0;
player.on("frame", () => frameCount++);
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
if (path === "URL") {
player.play("https://cdn.example/audio?token=input-secret");
} else {
// The real downloader marks playing before calling this file path.
const internal = player as unknown as {
state: string;
spawnFfmpegFromFile(file: string, seek: number, session: number): void;
};
internal.state = "playing";
internal.spawnFfmpegFromFile("downloaded.audio", 0, player.getPlaybackSessionId());
}
const outcome = await Promise.race([
producerProcess.closed,
new Promise<string>((resolve) => {
deadline = setTimeout(() => resolve("stderr blocked audio output"), 1500);
}),
]);
expect(outcome).toBe(1);
expect(producerProcess.args).toContain("-nostats");
await vi.waitFor(() => expect(frameCount).toBeGreaterThan(0));
const exit = records.find((record) => record.message === "FFmpeg exited");
expect(exit?.fields.stderr).toContain("final decoder failure");
expect(exit?.fields.stderr).toContain("HTTP error 403");
const logged = JSON.stringify(records);
for (const secret of ["password", "signed-secret", "fragment-secret", "cookie-secret", "bearer-secret", "input-secret"]) {
expect(logged).not.toContain(secret);
}
expect(String(exit?.fields.stderr).length).toBeLessThanOrEqual(4096);
} finally {
if (deadline) clearTimeout(deadline);
player.stop();
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
await producerProcess.closed;
}
});
it(`does not expose the ${path} input credentials through serialized spawn errors`, async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
let child!: ChildProcess;
let closed!: Promise<void>;
vi.mocked(spawn).mockImplementationOnce((_command, args, options) => {
child = actual.spawn(join(tmpdir(), "tsbot-ffmpeg-does-not-exist"), args, options);
closed = new Promise((resolve) => child.once("close", () => resolve()));
return child;
});
const player = new AudioPlayer(silentLogger);
const emitted = new Promise<Error>((resolve) => player.once("error", resolve));
try {
const input = "https://user:spawn-password@cdn.example/audio?token=spawn-secret";
if (path === "URL") {
player.play(input);
} else {
(player as unknown as { spawnFfmpegFromFile(file: string, seek: number, session: number): void })
.spawnFfmpegFromFile(input, 0, player.getPlaybackSessionId());
}
const error = await emitted;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain("ENOENT");
const serialized = JSON.stringify(pino.stdSerializers.err(error));
expect(serialized).not.toContain("spawn-secret");
expect(serialized).not.toContain("spawn-password");
await closed;
} finally {
player.stop();
}
});
}
it("logs intentional stop signals at info level", async () => {
const { records, logger } = recordedLogger();
const producerProcess = await pipeProducer("process.stdout.write(Buffer.from([0])); setInterval(() => {}, 1000);");
const player = new AudioPlayer(logger);
try {
player.play("https://cdn.example/audio");
await new Promise<void>((resolve) => producerProcess.child.stdout!.once("data", () => resolve()));
player.stop();
await producerProcess.closed;
const exit = records.find((record) => record.message === "FFmpeg exited");
expect(exit?.level).toBe("info");
} finally {
player.stop();
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
await producerProcess.closed;
}
});
it("reports a sanitized diagnostic tail when a child exits from an unexpected signal", async () => {
const { records, logger } = recordedLogger();
const child = Object.assign(new EventEmitter(), {
pid: undefined,
stdout: new PassThrough(),
stderr: new PassThrough(),
});
vi.mocked(spawn).mockReturnValueOnce(child as unknown as ChildProcess);
const player = new AudioPlayer(logger);
try {
player.play("https://cdn.example/audio");
child.stderr.write("decoder crashed for https://cdn.example/audio?token=signal-secret");
const ended = new Promise<void>((resolve) => child.stderr.once("end", resolve));
child.stderr.end();
await ended;
child.emit("exit", null, "SIGSEGV");
child.emit("close", null, "SIGSEGV");
const exit = records.find((record) => record.message === "FFmpeg exited");
expect(exit?.level).toBe("warn");
expect(exit?.fields.signal).toBe("SIGSEGV");
expect(exit?.fields.stderr).toContain("decoder crashed");
expect(JSON.stringify(records)).not.toContain("signal-secret");
} finally {
player.stop();
child.stdout.destroy();
child.stderr.destroy();
}
});
it("keeps draining an old child's stderr without mixing its late diagnostics or exit into a new session", async () => {
const { records, logger } = recordedLogger();
const makeChild = () => Object.assign(new EventEmitter(), {
pid: undefined,
stdout: new PassThrough(),
stderr: new PassThrough(),
});
const oldChild = makeChild();
const newChild = makeChild();
vi.mocked(spawn)
.mockReturnValueOnce(oldChild as unknown as ChildProcess)
.mockReturnValueOnce(newChild as unknown as ChildProcess);
const player = new AudioPlayer(logger);
try {
player.play("https://cdn.example/old");
const oldSession = player.getPlaybackSessionId();
player.play("https://cdn.example/new");
const newSession = player.getPlaybackSessionId();
oldChild.stderr.write("old late decoder failure https://cdn.example/old?token=old-secret\n".repeat(1000));
newChild.stderr.write("new decoder failure\n");
await new Promise<void>((resolve) => setImmediate(resolve));
expect(oldChild.stderr.readableLength).toBe(0);
oldChild.emit("exit", 1, null);
oldChild.stderr.end();
oldChild.emit("close", 1, null);
expect(player.getState()).toBe("playing");
vi.useFakeTimers();
const internal = player as unknown as { frameLoopRunning: boolean; startFrameLoop(): void };
internal.frameLoopRunning = false;
internal.startFrameLoop();
vi.advanceTimersByTime(6000);
const stall = records.find((record) => record.message === "FFmpeg stopped outputting data, ending track");
expect(stall?.fields.sessionId).toBe(newSession);
expect(stall?.fields.stderr).toContain("new decoder failure");
expect(stall?.fields.stderr).not.toContain("old late decoder failure");
const oldExit = records.find((record) => record.message === "FFmpeg exited");
expect(oldExit?.fields.sessionId).toBe(oldSession);
expect(JSON.stringify(records)).not.toContain("old-secret");
} finally {
vi.useRealTimers();
player.stop();
oldChild.stdout.destroy();
oldChild.stderr.destroy();
newChild.stdout.destroy();
newChild.stderr.destroy();
}
});
it("includes the current child's sanitized diagnostic tail when the stall watchdog ends playback", async () => {
const { records, logger } = recordedLogger();
const producerProcess = await pipeProducer(`
process.stderr.write('HTTP error 403: https://cdn.example/audio?token=stall-secret\\n');
process.stdout.write(Buffer.from([0]));
setInterval(() => {}, 1000);
`);
const player = new AudioPlayer(logger);
try {
player.play("https://cdn.example/audio");
await new Promise<void>((resolve) => producerProcess.child.stdout!.once("data", () => resolve()));
vi.useFakeTimers();
// Restart scheduling under the test clock, without changing EOF state.
const internal = player as unknown as { frameLoopRunning: boolean; startFrameLoop(): void };
internal.frameLoopRunning = false;
internal.startFrameLoop();
vi.advanceTimersByTime(6000);
const stall = records.find((record) => record.message === "FFmpeg stopped outputting data, ending track");
expect(stall?.fields.stderr).toContain("HTTP error 403");
expect(JSON.stringify(records)).not.toContain("stall-secret");
expect(player.getState()).toBe("idle");
} finally {
vi.useRealTimers();
player.stop();
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
await producerProcess.closed;
}
});
});
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 {
+134 -18
View File
@@ -7,6 +7,7 @@ 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";
import { collectFfmpegDiagnostics, type FfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
@@ -52,6 +53,17 @@ export function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
function safeFfmpegSpawnError(err: Error): Error {
// Node spawn errors include spawnargs; Pino's Error serializer copies them,
// including the signed input URL. Preserve a known OS category only.
const allowedCodes = new Set(["ENOENT", "EACCES", "EPERM", "ENOEXEC", "EMFILE", "ENFILE", "ENOMEM", "EAGAIN", "EINVAL"]);
const code = (err as NodeJS.ErrnoException).code;
const safeCode = typeof code === "string" && allowedCodes.has(code) ? code : undefined;
const safeError = new Error(`FFmpeg failed to start${safeCode ? ` (${safeCode})` : ""}`);
if (safeCode) Object.assign(safeError, { code: safeCode });
return safeError;
}
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
@@ -74,10 +86,11 @@ export function cleanupTempDir(dir: string): void {
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const args: string[] = ["-nostats"];
const isHttp = /^https?:\/\//i.test(url);
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
if (isBilibili) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
@@ -103,8 +116,16 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
// to the byte offset. Output-side seek would download and decode everything
// before the target first — minutes for a resume deep into a 3-hour video
// (#161), long enough to trip the stall watchdog.
const inputSideSeek = isBilibili;
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
@@ -158,9 +179,20 @@ const FRAME_DURATION_MS = 20;
export class AudioPlayer extends EventEmitter {
private ffmpeg: ChildProcess | null = null;
private ffmpegDiagnostics: FfmpegDiagnostics | null = null;
private readonly intentionalCleanup = new WeakSet<ChildProcess>();
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;
@@ -240,6 +272,9 @@ export class AudioPlayer extends EventEmitter {
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = this.ffmpeg;
const diagnostics = collectFfmpegDiagnostics(child.stderr);
this.ffmpegDiagnostics = diagnostics;
const currentPid = this.ffmpeg.pid;
if (currentPid) {
@@ -262,7 +297,6 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
@@ -270,11 +304,21 @@ export class AudioPlayer extends EventEmitter {
}
});
// close follows stderr's end, so final unterminated diagnostics are ready.
this.ffmpeg.on("close", (code, signal) => {
const context = { pid: currentPid, sessionId: currentSessionId, code, signal };
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
} else {
this.logger.info(context, "FFmpeg exited");
}
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === currentSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
this.emit("error", safeFfmpegSpawnError(err));
}
});
@@ -314,10 +358,7 @@ export class AudioPlayer extends EventEmitter {
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
const diagnostics = collectFfmpegDiagnostics(ps.stderr);
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
@@ -326,7 +367,6 @@ export class AudioPlayer extends EventEmitter {
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
@@ -338,6 +378,12 @@ export class AudioPlayer extends EventEmitter {
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("close", (code, signal) => {
if (!this.intentionalCleanup.has(ps) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ pid: ps.pid, sessionId, code, signal, stderr: diagnostics.getTail() }, "PowerShell download failed");
}
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
@@ -368,6 +414,9 @@ export class AudioPlayer extends EventEmitter {
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = this.ffmpeg;
const diagnostics = collectFfmpegDiagnostics(child.stderr);
this.ffmpegDiagnostics = diagnostics;
const currentPid = this.ffmpeg.pid;
if (currentPid) {
@@ -387,7 +436,6 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
@@ -395,11 +443,20 @@ export class AudioPlayer extends EventEmitter {
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("close", (code, signal) => {
const context = { pid: currentPid, sessionId, code, signal };
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
} else {
this.logger.info(context, "FFmpeg exited");
}
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
this.emit("error", safeFfmpegSpawnError(err));
}
});
@@ -525,6 +582,7 @@ export class AudioPlayer extends EventEmitter {
// 立即清空缓冲区,确保切歌瞬间静音 (
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegDiagnostics = null;
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
@@ -539,6 +597,7 @@ export class AudioPlayer extends EventEmitter {
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
this.intentionalCleanup.add(ps);
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
@@ -562,6 +621,7 @@ export class AudioPlayer extends EventEmitter {
}
private forceCleanup(proc: ChildProcess, pid: number): void {
this.intentionalCleanup.add(proc);
if (!globalActivePids.has(pid)) return;
try {
@@ -646,6 +706,7 @@ export class AudioPlayer extends EventEmitter {
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
stderr: this.ffmpegDiagnostics?.getTail() ?? "",
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
// The outer gate guarantees state==="playing" here, so no !=="idle"
@@ -732,17 +793,54 @@ export class AudioPlayer extends EventEmitter {
}
private applyVolume(pcm: Buffer): Buffer {
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const baseFactor = volumeToFactor(this.volume);
const now = performance.now();
const startDuckingGain = this.duckingGainAt(now);
const endDuckingGain = this.duckingGainAt(now + FRAME_DURATION_MS);
const startFactor = baseFactor * startDuckingGain;
const endFactor = baseFactor * endDuckingGain;
if (startFactor >= 1 && endFactor >= 1) {
return Buffer.from(pcm);
}
const out = Buffer.alloc(pcm.length);
// Most frames are outside the short attack/release windows. Preserve the
// old constant-factor hot path instead of doing interpolation per sample.
if (startFactor === endFactor) {
for (let i = 0; i < pcm.length; i += 2) {
const sample = Math.round(pcm.readInt16LE(i) * startFactor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
// PCM is fixed at stereo s16le. Use one gain for each L/R pair so a ramp
// never creates a tiny channel imbalance, and span the whole 20 ms frame.
const stereoFrames = Math.max(1, Math.ceil(pcm.length / 4));
for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
const frameIndex = Math.floor(i / 4);
const progress = stereoFrames === 1 ? 0 : frameIndex / (stereoFrames - 1);
const factor = startFactor + (endFactor - startFactor) * progress;
const sample = Math.round(pcm.readInt16LE(i) * factor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
private duckingGainAt(at: number): number {
if (this.duckingRampDurationMs <= 0) return this.duckingTargetGain;
const progress = Math.max(
0,
Math.min(1, (at - this.duckingRampStartedAt) / this.duckingRampDurationMs),
);
return (
this.duckingRampStartGain +
(this.duckingTargetGain - this.duckingRampStartGain) * progress
);
}
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
// (framesPlayed includes silence frames emitted on underrun) and therefore
// only APPROXIMATE — the authoritative position is the controller's live
@@ -762,9 +860,27 @@ 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; }
/** Changes on stop or a new play/seek, so asynchronous recovery can be fenced. */
getPlaybackSessionId(): number { return this.sessionId; }
// 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");
});
});
});
+85 -10
View File
@@ -17,6 +17,18 @@ export interface QueuedSong {
requestedBy?: string;
}
/**
* A persistable view of a queue: its songs (minus the lazily-resolved `url`),
* the current index, and the play mode. Used to snapshot/restore the live queue
* across restarts (issue #119). Derived state (playedIndices/history/forward
* stack) is intentionally NOT captured — restore() rebuilds it consistently.
*/
export interface QueueSnapshot {
songs: Omit<QueuedSong, "url">[];
currentIndex: number;
mode: PlayMode;
}
export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
@@ -48,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) {
@@ -68,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 {
@@ -94,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;
}
@@ -146,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
@@ -273,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 = [];
}
}
+964
View File
@@ -1,6 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } from "./instance.js";
import { PlayQueue, PlayMode } from "../audio/queue.js";
import { AudioPlayer } from "../audio/player.js";
import { createDatabase, SHARED_QUEUE_OWNER } from "../data/database.js";
import { parseCommand } from "./commands.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
@@ -8,6 +13,7 @@ import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.js";
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
@@ -119,6 +125,155 @@ describe("BotInstance.runExclusive — serialization", () => {
});
});
describe("BotInstance voice-ducking lifecycle integration", () => {
const connect = BotInstance.prototype.connect as unknown as (
this: Record<string, any>,
) => Promise<void>;
function makeConnectContext(connectPromise: Promise<void>) {
return {
disconnectEmitted: false,
connected: false,
tsClient: {
connect: vi.fn(() => connectPromise),
getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })),
},
configuredVoiceServerScope: {
host: "voice-alias.example.com",
voicePort: 9987,
},
voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 },
voiceDucking: { reset: vi.fn() },
registerManagedVoiceClient: vi.fn(),
profileManager: { onConnect: vi.fn() },
emit: vi.fn(),
restoreQueueFromSnapshot: vi.fn(async () => {}),
};
}
it("registers its managed client only after a successful outer connect", async () => {
const ctx = makeConnectContext(Promise.resolve());
await connect.call(ctx);
expect(ctx.connected).toBe(true);
expect(ctx.voiceServerScope).toEqual({ host: "203.0.113.20", voicePort: 12000 });
expect(ctx.voiceDucking.reset).toHaveBeenCalledWith(true);
expect(ctx.registerManagedVoiceClient).toHaveBeenCalledOnce();
expect(ctx.profileManager.onConnect).toHaveBeenCalledOnce();
});
it("does not register a late handshake after disconnect aborted it", async () => {
const handshake = deferred();
const ctx = makeConnectContext(handshake.promise);
const result = connect.call(ctx);
ctx.disconnectEmitted = true;
handshake.resolve();
await expect(result).rejects.toThrow("Connect aborted by concurrent disconnect");
expect(ctx.connected).toBe(false);
expect(ctx.registerManagedVoiceClient).not.toHaveBeenCalled();
expect(ctx.voiceDucking.reset).not.toHaveBeenCalled();
});
it("falls back to the configured endpoint when identity discovery is unavailable", async () => {
const ctx = makeConnectContext(Promise.resolve());
ctx.tsClient.getResolvedVoiceEndpoint.mockReturnValue(null as any);
await connect.call(ctx);
expect(ctx.voiceServerScope).toEqual({
host: "voice-alias.example.com",
voicePort: 9987,
});
});
it("routes human voice activity but filters another managed bot", () => {
const tsClient = new EventEmitter() as EventEmitter & {
getClientId(): number;
};
tsClient.getClientId = () => 10;
const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
managedVoiceClients.register(
{ host: "192.168.1.10", voicePort: 20_000 },
20,
{},
"managed-bot-uid=",
);
managedVoiceClients.register(voiceServerScope, 22, {}, "fallback-bot-uid=");
const handleVoiceActivity = vi.fn();
const ctx = {
tsClient,
connected: true,
managedVoiceClients,
voiceServerScope,
voiceDucking: {
handleVoiceActivity,
removeSpeaker: vi.fn(),
reset: vi.fn(),
},
} as Record<string, any>;
(BotInstance.prototype as any).setupTsEvents.call(ctx);
tsClient.emit("voiceActivity", {
clientId: 20,
codec: 5,
clientUid: "managed-bot-uid=",
});
// If a UID is momentarily unavailable, the scoped client-id registry is
// retained as a fallback for the common same-endpoint case.
tsClient.emit("voiceActivity", { clientId: 22, codec: 5 });
tsClient.emit("voiceActivity", {
clientId: 21,
codec: 5,
clientUid: "human-uid=",
});
expect(handleVoiceActivity).toHaveBeenCalledOnce();
expect(handleVoiceActivity).toHaveBeenCalledWith(21);
});
it("keeps a disconnecting bot registered during the in-flight packet grace", () => {
vi.useFakeTimers();
try {
const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
const owner = {};
managedVoiceClients.register(
voiceServerScope,
20,
owner,
"managed-bot-uid=",
);
const ctx = {
managedVoiceClients,
voiceServerScope,
registeredVoiceClientId: 20,
registeredVoiceClientOwner: owner,
registeredVoiceClientScope: voiceServerScope,
registeredVoiceClientUid: "managed-bot-uid=",
};
(BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000);
// A reconnect may resolve to a new endpoint before the grace expires;
// cleanup must still target the scope that owned the old client id.
ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 };
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true);
vi.advanceTimersByTime(999);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
vi.advanceTimersByTime(1);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
@@ -832,6 +987,9 @@ describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1
const database = {
getProfileConfig: () => ({}),
getCustomAvatarPath: () => null,
getPlayerSettings: () => ({ volume: 75, playMode: "seq" }),
saveVolume: () => {},
savePlayMode: () => {},
} as unknown as BotDatabase;
const options: BotInstanceOptions = {
id: "bot-oauth-test",
@@ -912,6 +1070,136 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
});
});
// --- Persisting volume + play mode across restarts (#125) ------------------
const cmdVol = (BotInstance.prototype as any).cmdVol as (this: unknown, cmd: any) => string;
const cmdMode = (BotInstance.prototype as any).cmdMode as (this: unknown, cmd: any) => string;
describe("BotInstance.cmdVol — persistence (#125)", () => {
function makeVolCtx() {
let stored = 75;
return {
id: "bot1",
player: {
setVolume: vi.fn((v: number) => { stored = v; }),
getVolume: vi.fn(() => stored),
},
database: { saveVolume: vi.fn() },
logger: { warn: vi.fn() },
emit: vi.fn(),
// The real private persist helper lives on the prototype; wire it so the
// test exercises the shipped persistence path end-to-end.
persistVolume: (BotInstance.prototype as any).persistVolume,
} as any;
}
it("saves the new volume via database.saveVolume (covers chat !vol AND the REST endpoint)", () => {
const ctx = makeVolCtx();
const res = cmdVol.call(ctx, { args: "40" });
expect(res).toBe("Volume set to 40%");
expect(ctx.player.setVolume).toHaveBeenCalledWith(40);
expect(ctx.database.saveVolume).toHaveBeenCalledWith("bot1", 40);
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("does not persist an out-of-range volume", () => {
const ctx = makeVolCtx();
const res = cmdVol.call(ctx, { args: "999" });
expect(res).toBe("Usage: !vol <0-100>");
expect(ctx.player.setVolume).not.toHaveBeenCalled();
expect(ctx.database.saveVolume).not.toHaveBeenCalled();
});
it("swallows a database error so the volume change still succeeds", () => {
const ctx = makeVolCtx();
ctx.database.saveVolume = vi.fn(() => { throw new Error("disk full"); });
const res = cmdVol.call(ctx, { args: "50" });
expect(res).toBe("Volume set to 50%");
expect(ctx.player.setVolume).toHaveBeenCalledWith(50);
expect(ctx.logger.warn).toHaveBeenCalled();
});
});
describe("BotInstance.cmdMode — persistence (#125)", () => {
function makeModeCtx() {
let mode = "seq";
return {
id: "bot1",
queue: {
setMode: vi.fn((m: string) => { mode = m; }),
getMode: vi.fn(() => mode),
},
database: { savePlayMode: vi.fn() },
logger: { warn: vi.fn() },
emit: vi.fn(),
persistPlayMode: (BotInstance.prototype as any).persistPlayMode,
} as any;
}
it("saves the new play mode via database.savePlayMode", () => {
const ctx = makeModeCtx();
const res = cmdMode.call(ctx, { args: "rloop" });
expect(res).toBe("Play mode set to: rloop");
expect(ctx.queue.setMode).toHaveBeenCalledWith("rloop");
expect(ctx.database.savePlayMode).toHaveBeenCalledWith("bot1", "rloop");
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("does not persist an unknown mode", () => {
const ctx = makeModeCtx();
const res = cmdMode.call(ctx, { args: "bogus" });
expect(res).toBe("Usage: !mode <seq|loop|random|rloop>");
expect(ctx.queue.setMode).not.toHaveBeenCalled();
expect(ctx.database.savePlayMode).not.toHaveBeenCalled();
});
});
describe("BotInstance — restores persisted player settings on construction (#125)", () => {
const provider = { platform: "netease" } as unknown as MusicProvider;
function makeOptions(id: string, database: BotDatabase): BotInstanceOptions {
const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
return {
id,
name: "RestoreBot",
tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "RestoreBot" } as any,
neteaseProvider: provider,
qqProvider: provider,
bilibiliProvider: provider,
youtubeProvider: provider,
database,
config: { spotify: {} } as unknown as BotConfig,
logger,
avatarStore: { read: () => null } as unknown as AvatarStore,
spotifyControllerFactory: () => ({ on: () => {} } as unknown as SpotifyController),
};
}
it("applies the saved volume + play mode from the database", () => {
const db = createDatabase(":memory:");
db.saveBotInstance({
id: "bot-restore", name: "B", serverAddress: "x", serverPort: 9987, nickname: "n",
defaultChannel: "", channelId: "", channelPassword: "", autoStart: false,
serverProtocol: "", ts6ApiKey: "", serverPassword: "",
});
db.saveVolume("bot-restore", 33);
db.savePlayMode("bot-restore", "loop");
const bot = new BotInstance(makeOptions("bot-restore", db));
const status = bot.getStatus();
expect(status.volume).toBe(33);
expect(status.playMode).toBe("loop");
db.close();
});
it("falls back to defaults for a bot with no saved settings", () => {
const db = createDatabase(":memory:");
const bot = new BotInstance(makeOptions("brand-new", db));
const status = bot.getStatus();
expect(status.volume).toBe(75);
expect(status.playMode).toBe("seq");
db.close();
});
});
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
it("splits a long command response into multiple sends, each under the byte cap", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
@@ -972,3 +1260,679 @@ describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
});
});
// ─── Saved queues + live-queue persistence + playKeepsQueue (#119) ─────────
// All exercise the ACTUAL shipped methods via their prototype, bound to a
// minimal ctx — the same lightweight pattern as the cmd* tests above.
const playSingleSong = BotInstance.prototype.playSingleSong as (
this: unknown,
song: unknown,
requesterName?: string,
) => Promise<boolean>;
const loadSavedQueue = BotInstance.prototype.loadSavedQueue as (
this: unknown,
songs: unknown[],
mode: "replace" | "append",
requesterName?: string,
) => Promise<void>;
const cmdSaveQueue = (BotInstance.prototype as any).cmdSaveQueue as (this: unknown, cmd: any) => string;
const cmdLoadQueue = (BotInstance.prototype as any).cmdLoadQueue as (this: unknown, cmd: any) => Promise<string>;
const cmdListQueues = (BotInstance.prototype as any).cmdListQueues as (this: unknown) => string;
const persistQueueSnapshot = (BotInstance.prototype as any).persistQueueSnapshot as (this: unknown) => void;
const scheduleQueueSnapshot = (BotInstance.prototype as any).scheduleQueueSnapshot as (this: unknown) => void;
const restoreQueueFromSnapshot = (BotInstance.prototype as any).restoreQueueFromSnapshot as (this: unknown) => Promise<void>;
const withRequester = (BotInstance.prototype as any).withRequester;
const isSameSong = (BotInstance.prototype as any).isSameSong;
const savedQueuesGuard = (BotInstance.prototype as any).savedQueuesGuard;
function song119(id: string) {
return { id, name: id, artist: "", album: "", platform: "netease" as const, coverUrl: "", duration: 1 };
}
function makePlayer119() {
let state: "idle" | "playing" | "paused" = "idle";
return {
stop: vi.fn(() => { state = "idle"; }),
resetFailures: vi.fn(),
getState: vi.fn(() => state),
_play: () => { state = "playing"; },
};
}
describe("BotInstance.playSingleSong / playKeepsQueue (#119)", () => {
function makeCtx(playKeepsQueue: boolean) {
const queue = new PlayQueue();
return {
config: { playKeepsQueue },
queue,
player: makePlayer119(),
withRequester,
isSameSong,
disableFmMode: vi.fn(),
sweepLocalAudio: vi.fn(),
resolveAndPlay: vi.fn(async () => true),
} as any;
}
it("clears the queue when playKeepsQueue is false (default)", async () => {
const ctx = makeCtx(false);
ctx.queue.add(song119("a"));
ctx.queue.play();
const ok = await playSingleSong.call(ctx, song119("b"), "alice");
expect(ok).toBe(true);
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]);
expect(ctx.queue.current()?.id).toBe("b");
expect(ctx.sweepLocalAudio).toHaveBeenCalled();
});
it("inserts-after-current and keeps the queue when playKeepsQueue is true", async () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.add(song119("c"));
ctx.queue.play(); // current = a (index 0)
await playSingleSong.call(ctx, song119("b"), "alice");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b", "c"]);
expect(ctx.queue.current()?.id).toBe("b");
expect(ctx.queue.current()?.requestedBy).toBe("alice");
// Keep-queue mode must not sweep local uploads (nothing was released).
expect(ctx.sweepLocalAudio).not.toHaveBeenCalled();
});
it("falls back to clear-and-play when playKeepsQueue is true but the queue is empty", async () => {
const ctx = makeCtx(true);
await playSingleSong.call(ctx, song119("b"), "alice");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]);
expect(ctx.queue.current()?.id).toBe("b");
});
});
describe("BotInstance.loadSavedQueue (#119)", () => {
function makeCtx() {
const player = makePlayer119();
return {
queue: new PlayQueue(),
player,
withRequester,
disableFmMode: vi.fn(),
sweepLocalAudio: vi.fn(),
resolveAndPlay: vi.fn(async () => { player._play(); return true; }),
emit: vi.fn(),
} as any;
}
it("replace clears + plays from the first track", async () => {
const ctx = makeCtx();
ctx.queue.add(song119("old"));
ctx.queue.play();
await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "replace", "bob");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]);
expect(ctx.queue.current()?.id).toBe("a");
expect(ctx.queue.current()?.requestedBy).toBe("bob");
expect(ctx.disableFmMode).toHaveBeenCalled();
expect(ctx.resolveAndPlay).toHaveBeenCalled();
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("append adds to the end and starts playing only when idle", async () => {
const ctx = makeCtx();
// Idle bot with an existing (not playing) queue entry.
ctx.queue.add(song119("x"));
await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "append");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a", "b"]);
// wasIdle → start the first appended song (index 1).
expect(ctx.queue.current()?.id).toBe("a");
expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1);
});
it("append does not interrupt a playing track", async () => {
const ctx = makeCtx();
ctx.player._play(); // player is 'playing'
ctx.queue.add(song119("x"));
ctx.queue.play(); // current = x
await loadSavedQueue.call(ctx, [song119("a")], "append");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a"]);
expect(ctx.queue.current()?.id).toBe("x");
expect(ctx.resolveAndPlay).not.toHaveBeenCalled();
});
});
describe("BotInstance chat save/load/queues (#119)", () => {
function makeCtx(enabled: boolean, db = createDatabase(":memory:")) {
const queue = new PlayQueue();
return {
config: { savedQueuesEnabled: enabled, commandPrefix: "!" },
queue,
database: db,
savedQueuesGuard,
loadSavedQueue: vi.fn(async () => {}),
} as any;
}
it("replies 此功能未启用 when the feature is disabled", () => {
const ctx = makeCtx(false);
expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("此功能未启用");
expect(cmdListQueues.call(ctx)).toBe("此功能未启用");
});
it("refuses saving an empty queue", () => {
const ctx = makeCtx(true);
expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("队列为空,无法保存");
});
it("saves the current queue to the shared bucket and lists it", () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.add(song119("b"));
const reply = cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!);
expect(reply).toContain("已保存队列");
expect(ctx.database.listSavedQueues(SHARED_QUEUE_OWNER, false).map((x: any) => x.name)).toContain("night");
expect(cmdListQueues.call(ctx)).toContain("night");
});
it("loads a saved queue by name (replace by default, -a appends)", async () => {
const db = createDatabase(":memory:");
const ctx = makeCtx(true, db);
db.saveQueue(SHARED_QUEUE_OWNER, "night", [song119("a")]);
const rep = await cmdLoadQueue.call(ctx, parseCommand("!load night", "!")!);
expect(rep).toContain("已加载");
expect(ctx.loadSavedQueue).toHaveBeenCalledWith(expect.any(Array), "replace");
const repA = await cmdLoadQueue.call(ctx, parseCommand("!load -a night", "!")!);
expect(repA).toContain("已追加");
expect(ctx.loadSavedQueue).toHaveBeenLastCalledWith(expect.any(Array), "append");
});
it("reports a missing saved queue", async () => {
const ctx = makeCtx(true);
expect(await cmdLoadQueue.call(ctx, parseCommand("!load nope", "!")!)).toContain("找不到");
});
});
describe("BotInstance live-queue persistence (#119)", () => {
function makeCtx(enabled: boolean, db = createDatabase(":memory:")) {
return {
id: "bot1",
config: { savedQueuesEnabled: enabled },
queue: new PlayQueue(),
database: db,
isFmMode: false,
fmProvider: null,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
player: makePlayer119(),
resolveAndPlay: vi.fn(async () => true),
getProviderFor: vi.fn(() => ({ platform: "netease" })),
} as any;
}
it("persists a snapshot when enabled", () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.play();
persistQueueSnapshot.call(ctx);
const st = ctx.database.getQueueState("bot1")!;
expect(st.songs.map((s: any) => s.id)).toEqual(["a"]);
expect(st.currentIndex).toBe(0);
});
it("does NOT persist when the feature is disabled", () => {
const ctx = makeCtx(false);
ctx.queue.add(song119("a"));
ctx.queue.play();
persistQueueSnapshot.call(ctx);
expect(ctx.database.getQueueState("bot1")).toBeNull();
});
it("clears the persisted row when the queue is empty", () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
persistQueueSnapshot.call(ctx); // queue is empty
expect(db.getQueueState("bot1")).toBeNull();
});
it("restores and resumes the current track on restore", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a"), song119("b")], currentIndex: 1, mode: "loop", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]);
expect(ctx.queue.getCurrentIndex()).toBe(1);
expect(ctx.queue.getMode()).toBe(PlayMode.Loop);
expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1);
});
it("restores FM mode + provider from the snapshot", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" });
const ctx = makeCtx(true, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.isFmMode).toBe(true);
expect(ctx.getProviderFor).toHaveBeenCalledWith("qq");
});
it("does nothing when the feature is disabled", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(false, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.queue.list()).toEqual([]);
expect(ctx.resolveAndPlay).not.toHaveBeenCalled();
});
it("a cancelled snapshot timer does not wipe persisted state (disconnect race)", () => {
vi.useFakeTimers();
try {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
ctx.queue.add(song119("a"));
ctx.queue.play();
// Debounced snapshot scheduled, then a disconnect clears the queue and
// cancels the pending timer — the persisted row must survive for restore.
scheduleQueueSnapshot.call(ctx);
ctx.queue.clear();
if (ctx.snapshotTimer) clearTimeout(ctx.snapshotTimer);
vi.advanceTimersByTime(3000);
expect(db.getQueueState("bot1")).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
});
describe("BotInstance Bilibili multi-P resolution", () => {
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
const multiPSongDetail = {
id: "BV1multiP?p=1",
name: "测试视频 - P1 分P1",
artist: "UP主",
album: "",
duration: 100, // P1 duration
coverUrl: "",
platform: "bilibili" as const,
};
const mockBili = {
platform: "bilibili" as const,
search: vi.fn().mockResolvedValue({
songs: [{
id: "BV1multiP",
name: "测试视频",
artist: "UP主",
album: "",
duration: 300, // total duration in search
coverUrl: "",
platform: "bilibili",
}],
albums: [],
playlists: [],
}),
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
};
const ctx = {
config: { commandPrefix: "!" },
lastSearchResults: [] as any[],
getProvider: () => mockBili,
getProviderFor: () => mockBili,
};
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
name: "play",
args: "测试视频",
rawArgs: ["测试视频"],
flags: new Set(),
});
expect(res.song).toBeDefined();
expect(res.song.id).toBe("BV1multiP?p=1");
expect(res.song.name).toBe("测试视频 - P1 分P1");
expect(res.song.duration).toBe(100);
});
});
describe("cmdPlaylist with a playlist link (#160)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
) => Promise<string>;
function makeCtx() {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
const makeProvider = (platform: string) => ({
platform,
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
};
const queued: any[] = [];
return {
providers,
queued,
getProvider: vi.fn(() => providers.netease),
getProviderFor: vi.fn((p: string) => providers[p]),
assertProviderEnabled: vi.fn(),
extractId: (BotInstance.prototype as any).extractId,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
player: { stop: vi.fn() },
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
disableFmMode: vi.fn(),
withRequester: (s: any) => s,
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
};
}
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
const ctx = makeCtx();
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
expect(ctx.queued[0].platform).toBe("qq");
expect(reply).toMatch(/^Loaded 1 songs/);
});
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
});
it("checks the link's platform is enabled", async () => {
const ctx = makeCtx();
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
});
it("keeps the old behavior for a bare id", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282"));
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
});
});
describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => {
const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as (
this: unknown,
) => Promise<boolean>;
function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) {
const song: any = {
id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "",
platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old",
};
let elapsed = opts.elapsed ?? 1000;
let state: "idle" | "playing" = "idle";
const provider = {
getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })),
};
const ctx: any = {
song,
provider,
connected: true,
effectiveDuration: song.duration,
streamRecovery: null,
queue: { current: vi.fn(() => song) },
player: {
getElapsed: vi.fn(() => elapsed),
getState: vi.fn(() => state),
getPlaybackSessionId: vi.fn(() => 1),
play: vi.fn(() => { state = "playing"; }),
},
getProviderFor: vi.fn(() => provider),
logger: { warn: vi.fn(), info: vi.fn() },
emit: vi.fn(),
setElapsed: (v: number) => { elapsed = v; state = "idle"; },
};
return ctx;
}
it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => {
const ctx = makeCtx({ elapsed: 1000, duration: 10_000 });
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc");
expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000);
expect(ctx.song.url).toBe("https://fresh.test/a.m4s");
});
it("does nothing near the real end of the track (normal EOF)", async () => {
const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 });
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.provider.getSongUrl).not.toHaveBeenCalled();
});
it("does nothing for other platforms or an unknown duration", async () => {
expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false);
const unknown = makeCtx({ duration: 0 });
unknown.effectiveDuration = 0;
expect(await resumeInterruptedStream.call(unknown)).toBe(false);
});
it("gives up after 3 attempts that make no progress, then lets the queue advance", async () => {
const ctx = makeCtx({ elapsed: 1000 });
for (let i = 0; i < 3; i++) {
ctx.setElapsed(1000);
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
}
ctx.setElapsed(1000);
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.player.play).toHaveBeenCalledTimes(3);
});
it("resets the attempt budget once a resume actually plays on for a while", async () => {
const ctx = makeCtx({ elapsed: 1000 });
for (let i = 0; i < 3; i++) {
ctx.setElapsed(1000);
await resumeInterruptedStream.call(ctx);
}
ctx.setElapsed(2000); // the last resume played ~16 more minutes
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
});
it("falls through to advancing when no fresh URL can be fetched", async () => {
const ctx = makeCtx({ url: null });
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
expect(ctx.player.play).not.toHaveBeenCalled();
});
it("does not clobber a different track the user started while the URL was resolving", async () => {
const ctx = makeCtx();
ctx.provider.getSongUrl.mockImplementation(async () => {
ctx.queue.current.mockReturnValue({ id: "other" }); // user ran !next meanwhile
return { url: "https://fresh.test/a.m4s" };
});
expect(await resumeInterruptedStream.call(ctx)).toBe(true); // handled: don't advance again
expect(ctx.player.play).not.toHaveBeenCalled();
});
});
describe("BotInstance trackEnd — stale playback sessions", () => {
function makeEndedCtx(platform = "bilibili", duration = 10_000) {
const song = {
id: "ended", name: "Ended", artist: "A", album: "", coverUrl: "",
platform, duration, url: "old",
};
let current: any = song;
const player = new EventEmitter() as any;
player.state = "idle";
player.sessionId = 1;
player.getState = AudioPlayer.prototype.getState;
player.getElapsed = () => 1000;
player.getPlaybackSessionId = AudioPlayer.prototype.getPlaybackSessionId;
player.pause = AudioPlayer.prototype.pause;
player.resume = AudioPlayer.prototype.resume;
player.play = vi.fn(() => { player.sessionId++; player.state = "playing"; });
const provider = { getSongUrl: vi.fn(async () => ({ url: "fresh" })) };
const advances: string[] = [];
const ctx: any = {
song, provider, player, connected: true, effectiveDuration: duration,
streamRecovery: null, queue: { current: () => current },
spotifyController: new EventEmitter(), tsClient: { sendVoiceData: vi.fn() },
logger: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, emit: vi.fn(),
getProviderFor: () => provider,
playNext: vi.fn(async () => { advances.push(current?.id ?? "empty"); return true; }),
replace: () => { current = { ...song, id: "replacement" }; player.sessionId++; player.state = "playing"; },
stop: () => { current = null; player.sessionId++; player.state = "idle"; },
restartSameSong: () => { player.sessionId++; player.state = "idle"; },
pause: () => cmdPause.call(ctx),
resume: () => cmdResume.call(ctx),
advances,
};
ctx.resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream.bind(ctx);
setupPlayerEvents.call(ctx);
return ctx;
}
async function flushEvents() {
await new Promise<void>(resolve => setImmediate(resolve));
}
it.each(["netease", "bilibili"])("an old normal %s EOF never skips a pending replacement", async platform => {
const ctx = makeEndedCtx(platform, 1000);
const replacement = Promise.resolve().then(() => ctx.replace());
ctx.player.emit("trackEnd");
await replacement;
await flushEvents();
expect(ctx.advances).not.toContain("replacement");
});
it("normal EOF still advances the ending track when no replacement arrives", async () => {
const ctx = makeEndedCtx("netease", 1000);
ctx.player.emit("trackEnd");
await flushEvents();
expect(ctx.advances).toEqual(["ended"]);
});
it("a failed recovery never advances a replacement", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.replace();
lookup.reject(new Error("temporary lookup failure"));
await flushEvents();
expect(ctx.advances).toEqual([]);
});
it.each(["stop", "restartSameSong"])("recovery does not overwrite playback after %s", async action => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx[action]();
lookup.resolve({ url: "fresh" });
await flushEvents();
expect(ctx.player.play).not.toHaveBeenCalled();
expect(ctx.advances).toEqual([]);
});
it("pause during an idle URL lookup is honored by recovered playback, then resume continues", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.pause();
expect(ctx.player.getState()).toBe("idle"); // actual AudioPlayer.pause cannot pause idle
lookup.resolve({ url: "fresh" });
await flushEvents();
expect(ctx.player.play).toHaveBeenCalledWith("fresh", 1000, 10_000);
expect(ctx.player.getState()).toBe("paused");
expect(ctx.advances).toEqual([]);
ctx.resume();
expect(ctx.player.getState()).toBe("playing");
expect(ctx.player.play).toHaveBeenCalledTimes(1);
});
it("resume before a paused recovery lookup completes lets the fresh stream play", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.pause();
ctx.resume();
lookup.resolve({ url: "fresh" });
await flushEvents();
expect(ctx.player.getState()).toBe("playing");
expect(ctx.advances).toEqual([]);
expect(ctx.provider.getSongUrl).toHaveBeenCalledTimes(1);
expect(ctx.streamRecovery?.attempts).toBe(1);
});
it("resume during a pending lookup cannot start a failing duplicate and skip the song", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValueOnce(lookup.promise).mockResolvedValue(null);
ctx.player.emit("trackEnd");
ctx.pause();
ctx.resume();
await flushEvents();
expect(ctx.advances).toEqual([]);
expect(ctx.provider.getSongUrl).toHaveBeenCalledTimes(1);
lookup.resolve({ url: "fresh" });
await flushEvents();
expect(ctx.player.getState()).toBe("playing");
expect(ctx.streamRecovery?.attempts).toBe(1);
});
it("pause while a recovery lookup fails prevents automatic queue advancement", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.pause();
lookup.reject(new Error("temporary lookup failure"));
await flushEvents();
expect(ctx.advances).toEqual([]);
});
it("resume after a paused failed lookup retries recovery instead of remaining idle", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.pause();
lookup.reject(new Error("temporary lookup failure"));
await flushEvents();
ctx.provider.getSongUrl.mockResolvedValue({ url: "recovered" });
ctx.resume();
await flushEvents();
expect(ctx.player.getState()).toBe("playing");
expect(ctx.player.play).toHaveBeenCalledWith("recovered", 1000, 10_000);
expect(ctx.advances).toEqual([]);
});
it("a same-song restart cannot inherit pause intent from an older rejected lookup", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.pause();
ctx.restartSameSong();
lookup.reject(new Error("temporary lookup failure"));
await flushEvents();
ctx.provider.getSongUrl.mockResolvedValue({ url: "new-recovery" });
ctx.player.emit("trackEnd");
await flushEvents();
expect(ctx.player.getState()).toBe("playing");
expect(ctx.advances).toEqual([]);
});
it("a failed recovery cannot advance a newer session of the same queue song", async () => {
const ctx = makeEndedCtx();
const lookup = deferred<{ url: string }>();
ctx.provider.getSongUrl.mockReturnValue(lookup.promise);
ctx.player.emit("trackEnd");
ctx.restartSameSong();
lookup.reject(new Error("temporary lookup failure"));
await flushEvents();
expect(ctx.advances).toEqual([]);
});
});
+597 -37
View File
@@ -3,6 +3,7 @@ import {
TS3Client,
type TS3ClientOptions,
type TS3TextMessage,
type TS3VoiceActivity,
} from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.js";
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
@@ -12,15 +13,22 @@ import {
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import {
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
import {
isProviderEnabled,
defaultPlatform,
type BotConfig,
type SpotifyConfig,
type VoiceDuckingConfig,
} from "../data/config.js";
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
import { BotProfileManager } from "./profile.js";
@@ -35,10 +43,29 @@ import path from "node:path";
import { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import { VoiceDuckingController } from "./voice-ducking.js";
import {
ManagedVoiceClientRegistry,
type ManagedVoiceClientOwnerToken,
type ManagedVoiceClientScope,
} from "./managed-voice-clients.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
/** Maps the persisted / command-line play-mode string to the PlayMode enum.
* Shared by the !mode command and the restart-restore path (#125). */
const PLAY_MODE_BY_VALUE: Record<string, PlayMode> = {
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
// Keep a disconnected bot id classified as managed briefly so UDP packets
// already in flight cannot make another local bot duck during teardown.
const MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS = 1_000;
/** Fallback message when Spotify audio can't be served (backend unavailable
* OR a per-track playTrack failure against a dead/failed sidecar). */
const SPOTIFY_UNAVAILABLE_MESSAGE =
@@ -91,6 +118,8 @@ export interface BotInstanceOptions {
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
/** Shared across one manager so its bots do not trigger one another. */
managedVoiceClients?: ManagedVoiceClientRegistry;
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
spotifyDataDir?: string;
/** Process-wide shared Spotify OAuth (single account); injected into the
@@ -130,6 +159,14 @@ export class BotInstance extends EventEmitter {
private tsClient: TS3Client;
private player: AudioPlayer;
private voiceDucking: VoiceDuckingController;
private managedVoiceClients: ManagedVoiceClientRegistry;
private readonly configuredVoiceServerScope: ManagedVoiceClientScope;
private voiceServerScope: ManagedVoiceClientScope;
private registeredVoiceClientId = 0;
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
private registeredVoiceClientScope: ManagedVoiceClientScope | null = null;
private registeredVoiceClientUid: string | null = null;
private spotifyController: SpotifyController;
private queue: PlayQueue;
private neteaseProvider: MusicProvider;
@@ -162,10 +199,14 @@ export class BotInstance extends EventEmitter {
private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined;
/** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */
private streamRecovery: { song: QueuedSong; attempts: number; position: number; session: number; pauseRequested: boolean; inFlight: boolean } | null = null;
private playGate: Promise<unknown> = Promise.resolve();
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */
private jellyfinReporter: JellyfinPlaybackReporter | null = null;
/** Debounce handle for the live-queue snapshot writer (Feature 2, #119). */
private snapshotTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: BotInstanceOptions) {
super();
@@ -186,8 +227,32 @@ export class BotInstance extends EventEmitter {
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
this.voiceDucking = new VoiceDuckingController(
this.player,
this.config.voiceDucking ?? { enabled: false, volumePercent: 30 },
);
this.managedVoiceClients =
options.managedVoiceClients ?? new ManagedVoiceClientRegistry();
this.configuredVoiceServerScope = {
host: options.tsOptions.host,
voicePort: options.tsOptions.port,
};
this.voiceServerScope = { ...this.configuredVoiceServerScope };
this.queue = new PlayQueue();
// Restore persisted per-bot player settings (#125): volume + play mode
// survive restarts. getPlayerSettings returns validated values (the in-memory
// defaults when the row/column is absent), so this is a harmless no-op for a
// brand-new bot and reproduces the saved state for an existing one.
try {
const settings = this.database.getPlayerSettings(this.id);
this.player.setVolume(settings.volume);
const restoredMode = PLAY_MODE_BY_VALUE[settings.playMode];
if (restoredMode) this.queue.setMode(restoredMode);
} catch (err) {
this.logger.warn({ err }, "Failed to restore player settings — using defaults");
}
// Structural typing (like localProvider.sweepUnreferenced): only the real
// JellyfinProvider exposes createPlaybackReporter, so the netease fallback
// provider simply leaves reporting off.
@@ -238,7 +303,13 @@ export class BotInstance extends EventEmitter {
const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
// loadCustomAvatar, NOT setCustomAvatar (#148): we are still in the
// constructor, so tsClient has not connected. setCustomAvatar would
// start a file transfer right here and fail. profileManager.onConnect()
// uploads it for real once the handshake completes.
// `length > 0` because avatarStore.write is delete-then-write, so a
// crash mid-write leaves a 0-byte file that is truthy as a Buffer.
if (buf && buf.length > 0) this.profileManager.loadCustomAvatar(buf);
}
} catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
@@ -246,6 +317,11 @@ export class BotInstance extends EventEmitter {
this.setupPlayerEvents();
this.setupTsEvents();
// Feature 2 (#119): persist a debounced snapshot of the live queue whenever
// it changes, so it can be restored + resumed after a restart. Inert unless
// config.savedQueuesEnabled is on (checked inside the scheduler).
this.on("stateChange", () => this.scheduleQueueSnapshot());
}
private setupPlayerEvents(): void {
@@ -254,10 +330,30 @@ export class BotInstance extends EventEmitter {
});
this.player.on("trackEnd", () => {
this.logger.debug("Track ended, advancing queue");
this.playNext().catch((err) => {
this.logger.error({ err }, "playNext failed after trackEnd");
});
const endedSong = this.queue.current();
const endedSession = this.player.getPlaybackSessionId();
this.resumeInterruptedStream()
.catch((err) => {
this.logger.warn({ err }, "Stream resume failed");
return false;
})
.then((resumed) => {
if (resumed) return;
// A pending command may replace, stop, or restart the same queue
// song before this continuation. Only advance the session that ended.
if (
!this.connected ||
this.queue.current() !== endedSong ||
this.player.getPlaybackSessionId() !== endedSession ||
this.player.getState() !== "idle" ||
(this.streamRecovery?.song === endedSong && this.streamRecovery.pauseRequested)
) return;
this.logger.debug("Track ended, advancing queue");
return this.playNext();
})
.catch((err) => {
this.logger.error({ err }, "playNext failed after trackEnd");
});
});
this.player.on("error", (err: Error) => {
@@ -329,6 +425,17 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
this.voiceDucking.reset(true);
// Cancel any pending live-queue snapshot BEFORE clearing the queue: a
// debounced snapshot firing after clear() would persist an empty queue
// (clearQueueState), wiping the state we want to restore on reconnect —
// and since a manual stop→start reuses the same botId, that would clobber
// the new instance's restored row (#119).
if (this.snapshotTimer) {
clearTimeout(this.snapshotTimer);
this.snapshotTimer = null;
}
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
@@ -351,6 +458,17 @@ export class BotInstance extends EventEmitter {
this._startJellyfinReportPoller();
});
this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => {
if (!this.connected) return;
if (
this.managedVoiceClients.hasClientUid(activity.clientUid) ||
this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)
) {
return;
}
this.voiceDucking.handleVoiceActivity(activity.clientId);
});
// React near-instantly to channel membership changes. The 30s idle
// poller remains the fallback if any of these events are missed.
//
@@ -362,8 +480,65 @@ export class BotInstance extends EventEmitter {
this._resumeIfReturning();
void this.refreshOccupancy();
});
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
this.tsClient.on("clientLeave", (event: { id: number }) => {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
this.voiceDucking.reset(false);
// Carry the now-playing channel description over to the new
// channel instead of leaving it stale in the old one (#159).
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
this.logger.warn({ err }, "Channel description move update failed");
});
} else {
this.voiceDucking.removeSpeaker(event.id);
}
void this.refreshOccupancy();
});
}
private registerManagedVoiceClient(): void {
this.unregisterManagedVoiceClient();
const clientId = this.tsClient.getClientId();
if (!Number.isSafeInteger(clientId) || clientId <= 0) return;
const owner = {};
const scope = { ...this.voiceServerScope };
const clientUid = this.tsClient.getClientUid();
if (this.managedVoiceClients.register(scope, clientId, owner, clientUid)) {
this.registeredVoiceClientId = clientId;
this.registeredVoiceClientOwner = owner;
this.registeredVoiceClientScope = scope;
this.registeredVoiceClientUid = clientUid;
}
}
private unregisterManagedVoiceClient(graceMs = 0): void {
const clientId = this.registeredVoiceClientId;
const owner = this.registeredVoiceClientOwner;
const clientUid = this.registeredVoiceClientUid ?? undefined;
const scope = this.registeredVoiceClientScope
? { ...this.registeredVoiceClientScope }
: { ...this.voiceServerScope };
this.registeredVoiceClientId = 0;
this.registeredVoiceClientOwner = null;
this.registeredVoiceClientScope = null;
this.registeredVoiceClientUid = null;
if (clientId <= 0 || owner === null) return;
const unregister = () => {
this.managedVoiceClients.unregister(scope, clientId, owner, clientUid);
};
if (graceMs > 0) {
const timer = setTimeout(unregister, graceMs);
timer.unref?.();
} else {
unregister();
}
}
/**
@@ -402,6 +577,13 @@ export class BotInstance extends EventEmitter {
async connect(): Promise<void> {
this.disconnectEmitted = false;
await this.tsClient.connect();
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
this.voiceServerScope = {
host:
resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
voicePort:
resolvedEndpoint?.port ?? this.configuredVoiceServerScope.voicePort,
};
// Race guard: if disconnect() was called while the handshake was
// awaiting, don't flip connected back to true — that would leave the
// bot in an inconsistent state (externally "connected" but the tsClient
@@ -410,12 +592,31 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
// Register only after the outer lifecycle race guard succeeds. The TS
// wrapper emits its own "connected" event before connect() resolves, so
// registering in that callback could let a cancelled, late handshake
// overwrite a newer instance that reused the same client id.
this.voiceDucking.reset(true);
this.registerManagedVoiceClient();
this.profileManager.onConnect();
this.emit("connected");
// Feature 2 (#119): restore + resume the live queue persisted before the
// last shutdown. Best-effort and gated on savedQueuesEnabled; runs after
// the bot is fully connected so resolveAndPlay can actually push audio.
void this.restoreQueueFromSnapshot();
}
disconnect(): void {
this._cancelIdleTimer();
this.voiceDucking.reset(true);
// Cancel any pending live-queue snapshot before clearing so it can't fire
// afterwards and persist an empty queue over the state we keep for restore
// (#119). The disconnected handler cancels too, but do it here as well for
// the path where tsClient.disconnect() doesn't re-emit "disconnected".
if (this.snapshotTimer) {
clearTimeout(this.snapshotTimer);
this.snapshotTimer = null;
}
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
@@ -428,6 +629,10 @@ export class BotInstance extends EventEmitter {
this.emit("disconnected");
}
this.tsClient.disconnect();
// Stop outbound PCM and initiate the TeamSpeak disconnect before removing
// our id from the shared registry, minimizing the window in which another
// managed bot could mistake our final packet for a human speaker.
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
}
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
@@ -450,6 +655,12 @@ export class BotInstance extends EventEmitter {
}
}
/** Hot-apply voice ducking without mutating the user's base player volume. */
updateVoiceDucking(settings: VoiceDuckingConfig): void {
this.config.voiceDucking = { ...settings };
this.voiceDucking.updateSettings(settings);
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
@@ -698,6 +909,12 @@ export class BotInstance extends EventEmitter {
return this.cmdMove(cmd);
case "follow":
return this.cmdFollow(msg);
case "save":
return this.cmdSaveQueue(cmd);
case "load":
return this.cmdLoadQueue(cmd);
case "queues":
return this.cmdListQueues();
case "help":
return this.cmdHelp();
default:
@@ -784,6 +1001,14 @@ export class BotInstance extends EventEmitter {
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
const detail = await provider.getSongDetail(song.id);
if (detail) {
song.duration = detail.duration;
song.name = detail.name;
song.id = detail.id;
}
}
const result = await provider.getSongUrl(song.id);
if (!result?.url) {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
@@ -924,6 +1149,89 @@ export class BotInstance extends EventEmitter {
}
}
/** Platforms whose CDN stream can die mid-file on long content (#89, #161). */
private static readonly RESUMABLE_PLATFORMS: ReadonlySet<Platform> = new Set(["bilibili"]);
/** A track that ends within this many seconds of its duration ended normally. */
private static readonly STREAM_END_TOLERANCE_S = 30;
private static readonly MAX_STREAM_RESUMES = 3;
/**
* Called when the player reports a track end. If a B站 stream ended long
* before its known duration, the CDN dropped it (#161): fetch a fresh URL
* and continue from where it stopped instead of skipping the rest of a
* 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no
* real progress, so a truly broken stream still advances the queue.
*
* Returns true when it handled the end (resumed, or a newer track has
* already taken over), false when the caller should advance the queue.
*/
private async resumeInterruptedStream(): Promise<boolean> {
const song = this.queue.current();
if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) {
return false;
}
const duration = this.effectiveDuration ?? song.duration;
const position = Math.floor(this.player.getElapsed());
const endedSession = this.player.getPlaybackSessionId();
if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) {
return false;
}
const recovery = this.streamRecovery;
if (
!recovery ||
recovery.song !== song ||
recovery.session !== endedSession ||
position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S
) {
this.streamRecovery = { song, attempts: 0, position, session: endedSession, pauseRequested: false, inFlight: false };
}
const state = this.streamRecovery!;
if (state.inFlight) return true;
if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) {
this.logger.warn(
{ songId: song.id, position, duration, attempts: state.attempts },
"Stream keeps ending early — giving up and advancing",
);
this.streamRecovery = null;
return false;
}
state.attempts++;
state.position = position;
this.logger.warn(
{ songId: song.id, position, duration, attempt: state.attempts },
"Stream ended before the track did — resuming with a fresh URL",
);
state.inFlight = true;
let result: Awaited<ReturnType<MusicProvider["getSongUrl"]>>;
try {
result = await this.getProviderFor(song.platform).getSongUrl(song.id);
} finally {
state.inFlight = false;
}
// The user may have skipped/stopped while we were resolving; never
// clobber whatever is playing now.
if (
this.queue.current() !== song ||
this.player.getPlaybackSessionId() !== endedSession ||
this.player.getState() !== "idle"
) {
if (this.streamRecovery === state) this.streamRecovery = null;
return true;
}
if (!result?.url || !this.connected) return false;
song.url = result.url;
this.player.play(result.url, position, duration);
state.session = this.player.getPlaybackSessionId();
// During the lookup the ended player is idle, so pause() alone cannot
// remember the user's intent. Pause the recovered stream before it emits frames.
if (state.pauseRequested) this.player.pause();
this.emit("stateChange");
return true;
}
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try {
await this.profileManager.onSongChange(song);
@@ -935,9 +1243,9 @@ export class BotInstance extends EventEmitter {
/**
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
* forms (issue #90):
* 1) "#N" — the Nth result of the previous !search
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
* 1) "#N" — the Nth result of the previous !search
* 2) id <id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
*/
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
const args = (cmd.args ?? "").trim();
@@ -950,10 +1258,15 @@ export class BotInstance extends EventEmitter {
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
let song = this.lastSearchResults[sel - 1];
if (song.platform === "bilibili") {
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
if (detail) song = { ...detail, platform: "bilibili" };
}
return { song };
}
// 2) id:/URL — fetch that exact song.
// 2) id/URL — fetch that exact song.
const ref = parseSongRef(args);
if (ref) {
if (ref.platform) this.assertProviderEnabled(ref.platform);
@@ -967,7 +1280,12 @@ export class BotInstance extends EventEmitter {
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
let song = result.songs[0];
if (provider.platform === "bilibili") {
const detail = await provider.getSongDetail(song.id);
if (detail) song = detail;
}
return { song: { ...song, platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
@@ -981,38 +1299,107 @@ export class BotInstance extends EventEmitter {
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
);
return [
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id <id>):`,
...lines,
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const song0 = song!;
const ok = await this.playSingleSong(song0, requesterName);
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
}
/**
* Play a single resolved song immediately, honoring config.playKeepsQueue:
* - false (default): clear the queue and play only this song — today's
* behavior. The prior track is stopped and released local uploads swept.
* - true (and the queue is non-empty): insert the song after the current
* track and jump to it (reusing addNext + playAt — no new queue logic), so
* the rest of the queue survives and continues after it. FM auto-refill is
* stopped (manual takeover), but existing queued songs are preserved.
*
* Shared by chat !play and the web /play-song route so the toggle decision
* lives in exactly one place (#119). Returns true if a track started playing.
*/
async playSingleSong(song: QueuedSong, requesterName?: string): Promise<boolean> {
const s = this.withRequester(song, requesterName);
if (this.config.playKeepsQueue && !this.queue.isEmpty()) {
const insertedAt =
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.player.stop();
this.disableFmMode();
this.queue.addNext(s);
this.queue.playAt(insertedAt);
this.player.resetFailures();
// No sweep here: the queue is kept, so no local uploads were released.
return this.resolveAndPlay(this.queue.current()!);
}
// Legacy replace behavior (default).
const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) {
if (previous && !this.isSameSong(previous, s)) {
this.player.stop();
}
this.queue.clear();
this.disableFmMode();
this.queue.add(this.withRequester(song0, requesterName));
this.queue.add(s);
this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
// longer referenced (and get deleted), but song0 — if it is the same local
// upload that was already playing — stays referenced and is preserved.
// longer referenced (and get deleted), but the song — if it is the same
// local upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
return ok;
}
/**
* Load a saved song list into this bot's queue (#119). `replace` clears +
* plays from the first track (exits FM, like a fresh collection load);
* `append` adds to the end and only starts playing if the bot was idle
* (never interrupts a playing track). Loaded songs are re-tagged with the
* loader's name so play-history attribution stays correct.
*/
async loadSavedQueue(
songs: StoredSong[],
mode: "replace" | "append",
requesterName?: string,
): Promise<void> {
const tagged = songs.map((s) =>
this.withRequester({ ...(s as QueuedSong) }, requesterName),
);
if (mode === "replace") {
this.player.stop();
this.queue.clear();
this.disableFmMode();
for (const s of tagged) this.queue.add(s);
this.sweepLocalAudio("queue_replaced");
const first = this.queue.play();
this.player.resetFailures();
if (first) await this.resolveAndPlay(first);
} else {
const wasIdle = this.player.getState() === "idle";
const startAt = this.queue.size();
for (const s of tagged) this.queue.add(s);
if (wasIdle && this.queue.size() > startAt) {
this.queue.playAt(startAt);
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
}
}
this.emit("stateChange");
}
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
@@ -1036,7 +1423,7 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
@@ -1067,6 +1454,10 @@ export class BotInstance extends EventEmitter {
}
private cmdPause(): string {
const recovery = this.streamRecovery;
if (recovery && recovery.song === this.queue.current() && this.player.getState() === "idle") {
recovery.pauseRequested = true;
}
this.player.pause();
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.pause().catch((err) =>
@@ -1079,7 +1470,15 @@ export class BotInstance extends EventEmitter {
}
private cmdResume(): string {
const recovery = this.streamRecovery;
const retryInterrupted = recovery && recovery.song === this.queue.current() &&
recovery.session === this.player.getPlaybackSessionId() && !recovery.inFlight &&
recovery.pauseRequested && this.player.getState() === "idle";
if (recovery) recovery.pauseRequested = false;
this.player.resume();
// A lookup that failed while paused has no stream to resume. Re-enter
// the bounded end/recovery handler instead of reporting success forever idle.
if (retryInterrupted) this.player.emit("trackEnd");
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.resume().catch((err) =>
this.logger.warn({ err }, "Spotify resume failed"));
@@ -1136,10 +1535,36 @@ export class BotInstance extends EventEmitter {
const vol = parseInt(cmd.args, 10);
if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>";
this.player.setVolume(vol);
// Persist so the volume survives a restart (#125). Both the chat !vol command
// and the WebUI/REST volume endpoint funnel through here, so one write covers
// every entry point. Only volume is written — play mode is saved independently.
this.persistVolume();
this.emit("stateChange");
return `Volume set to ${vol}%`;
}
/** Persist the current volume (#125). Best-effort: a DB error must never break
* the volume change itself. */
private persistVolume(): void {
try {
this.database.saveVolume(this.id, this.player.getVolume());
} catch (err) {
this.logger.warn({ err }, "Failed to persist volume");
}
}
/** Persist the current play mode (#125). Best-effort, mirrors persistVolume.
* Called ONLY from the explicit !mode command — NOT from FM/artist mode, whose
* Random/Loop switch is a transient side effect that must not overwrite the
* user's saved preference. */
private persistPlayMode(): void {
try {
this.database.savePlayMode(this.id, this.queue.getMode());
} catch (err) {
this.logger.warn({ err }, "Failed to persist play mode");
}
}
private cmdNow(): string {
const song = this.queue.current();
if (!song) return "Nothing is playing";
@@ -1205,22 +1630,32 @@ export class BotInstance extends EventEmitter {
}
private cmdMode(cmd: ParsedCommand): string {
const modeMap: Record<string, PlayMode> = {
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
const mode = modeMap[cmd.args];
const mode = PLAY_MODE_BY_VALUE[cmd.args];
if (mode === undefined) return "Usage: !mode <seq|loop|random|rloop>";
this.queue.setMode(mode);
// Persist so the play mode survives a restart (#125). The chat !mode command
// and the WebUI/REST mode endpoint both funnel through here.
this.persistPlayMode();
this.emit("stateChange");
return `Play mode set to: ${cmd.args}`;
}
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args);
@@ -1228,7 +1663,9 @@ export class BotInstance extends EventEmitter {
let playlistId: string;
if (isDirectId || id !== cmd.args) {
if (ref) {
playlistId = ref.id;
} else if (isDirectId || id !== cmd.args) {
// Input is a direct ID or URL containing an ID — use existing logic
playlistId = id;
} else {
@@ -1439,6 +1876,122 @@ export class BotInstance extends EventEmitter {
return "Following you to your channel";
}
// ─── Saved queues (chat side, #119) ──────────────────────────────────────
// TeamSpeak users have no WebUI account, so chat save/load always uses the
// reserved SHARED_QUEUE_OWNER bucket. All three commands are inert (reply
// "此功能未启用") unless the admin enabled savedQueuesEnabled.
private savedQueuesGuard(): string | null {
return this.config.savedQueuesEnabled ? null : "此功能未启用";
}
private cmdSaveQueue(cmd: ParsedCommand): string {
const off = this.savedQueuesGuard();
if (off) return off;
const name = cmd.args.trim();
if (!name) return `Usage: ${this.config.commandPrefix}save <名称>`;
const songs = this.queue.list();
if (songs.length === 0) return "队列为空,无法保存";
try {
const saved = this.database.saveQueue(SHARED_QUEUE_OWNER, name, songs);
return `已保存队列「${name}」(${saved.songCount} 首)`;
} catch (err) {
return `保存失败:${(err as Error).message}`;
}
}
private async cmdLoadQueue(cmd: ParsedCommand): Promise<string> {
const off = this.savedQueuesGuard();
if (off) return off;
const name = cmd.args.trim();
if (!name) return `Usage: ${this.config.commandPrefix}load [-a] <名称>`;
const meta = this.database
.listSavedQueues(SHARED_QUEUE_OWNER, false)
.find((q) => q.name === name);
const full = meta ? this.database.getSavedQueue(meta.id) : null;
if (!full) return `找不到已保存队列「${name}」`;
const mode = cmd.flags.has("a") ? "append" : "replace";
await this.loadSavedQueue(full.songs, mode);
return mode === "append"
? `已追加「${name}」(${full.songs.length} 首)到队列`
: `已加载「${name}」(${full.songs.length} 首)`;
}
private cmdListQueues(): string {
const off = this.savedQueuesGuard();
if (off) return off;
const list = this.database.listSavedQueues(SHARED_QUEUE_OWNER, false);
if (list.length === 0) return "还没有已保存的队列";
return ["已保存队列:", ...list.map((q) => `• ${q.name}(${q.songCount} 首)`)].join("\n");
}
// ─── Live-queue persistence (Feature 2, #119) ────────────────────────────
/** Synchronous snapshot writer. Persists the live queue (or clears the row
* when empty). Best-effort — a DB failure logs and never interrupts play. */
private persistQueueSnapshot(): void {
if (!this.config.savedQueuesEnabled) return;
try {
const snap = this.queue.snapshot();
if (snap.songs.length === 0) {
this.database.clearQueueState(this.id);
return;
}
this.database.saveQueueState({
botId: this.id,
songs: snap.songs,
currentIndex: snap.currentIndex,
mode: snap.mode,
isFmMode: this.isFmMode,
fmPlatform: this.isFmMode && this.fmProvider ? this.fmProvider.platform : "",
});
} catch (err) {
this.logger.warn({ err }, "queue snapshot persist failed");
}
}
/** Debounce the snapshot writer (~1s) off the stateChange firehose. */
private scheduleQueueSnapshot(): void {
if (!this.config.savedQueuesEnabled) return;
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
this.snapshotTimer = setTimeout(() => this.persistQueueSnapshot(), 1000);
// Don't keep the event loop alive just for a pending snapshot.
this.snapshotTimer.unref?.();
}
/** Restore + resume the live queue after (re)connect. Best-effort: resumes
* the current track from its START (URLs are re-resolved; no persisted
* elapsed). Spotify resume depends on the sidecar being available. */
private async restoreQueueFromSnapshot(): Promise<void> {
if (!this.config.savedQueuesEnabled) return;
let st;
try {
st = this.database.getQueueState(this.id);
} catch (err) {
this.logger.warn({ err }, "queue snapshot restore failed to read state");
return;
}
if (!st || st.songs.length === 0) return;
this.queue.restore({
songs: st.songs,
currentIndex: st.currentIndex,
mode: st.mode as PlayMode,
});
if (st.isFmMode && st.fmPlatform) {
this.isFmMode = true;
this.fmProvider = this.getProviderFor(st.fmPlatform as Platform);
}
const current = this.queue.current();
if (current) {
this.player.resetFailures();
await this.resolveAndPlay(current);
}
this.logger.info(
{ count: st.songs.length, index: st.currentIndex },
"Restored live queue from snapshot",
);
}
private cmdHelp(): string {
const p = this.config.commandPrefix;
const def = defaultPlatform(this.config);
@@ -1454,8 +2007,8 @@ export class BotInstance extends EventEmitter {
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}search <name> — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
`${p}play id:<id> — Play an exact song by id / URL`,
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
`${p}play id <id> — Play an exact song by id / URL`,
`${p}add <song> — Add to queue (also accepts #N / id <id> / URL)`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
@@ -1468,6 +2021,13 @@ export class BotInstance extends EventEmitter {
`${p}album <name or id> — Load album`,
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
`${p}artist <name> — Play songs by artist (loop)`,
...(this.config.savedQueuesEnabled
? [
`${p}save <名称> — Save current queue`,
`${p}load [-a] <名称> — Load a saved queue (-a appends)`,
`${p}queues — List saved queues`,
]
: []),
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
`${p}now — Current song info`,
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
ManagedVoiceClientRegistry,
normalizeManagedVoiceClientScope,
normalizeManagedVoiceHost,
} from "./managed-voice-clients.js";
describe("managed voice client scope normalization", () => {
it("normalizes DNS host casing, whitespace, and trailing root dots", () => {
expect(normalizeManagedVoiceHost(" Voice.Example.COM... ")).toBe(
"voice.example.com",
);
expect(
normalizeManagedVoiceClientScope({
host: "VOICE.EXAMPLE.COM.",
voicePort: 9987,
}),
).toEqual({ host: "voice.example.com", voicePort: 9987 });
});
it("treats bracketed and equivalent expanded IPv6 literals as one host", () => {
expect(normalizeManagedVoiceHost("[2001:0DB8:0:0:0:0:0:1]")).toBe(
"2001:db8::1",
);
expect(normalizeManagedVoiceHost("2001:db8::1")).toBe("2001:db8::1");
});
it("rejects empty hosts and invalid voice ports", () => {
expect(
normalizeManagedVoiceClientScope({ host: " . ", voicePort: 9987 }),
).toBeNull();
expect(
normalizeManagedVoiceClientScope({ host: "example.com", voicePort: 0 }),
).toBeNull();
expect(
normalizeManagedVoiceClientScope({
host: "example.com",
voicePort: 65_536,
}),
).toBeNull();
});
});
describe("ManagedVoiceClientRegistry", () => {
it("finds clients through normalized forms of the same scope", () => {
const registry = new ManagedVoiceClientRegistry();
const owner = Symbol("connection");
expect(
registry.register(
{ host: " Voice.Example.COM. ", voicePort: 9987 },
42,
owner,
),
).toBe(true);
expect(
registry.has({ host: "voice.example.com", voicePort: 9987 }, 42),
).toBe(true);
});
it("keeps different voice ports and hosts in separate scopes", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "voice.example.com", voicePort: 9987 },
7,
Symbol("connection"),
);
expect(
registry.has({ host: "voice.example.com", voicePort: 9988 }, 7),
).toBe(false);
expect(
registry.has({ host: "other.example.com", voicePort: 9987 }, 7),
).toBe(false);
});
it("finds a managed bot by stable client UID across network endpoints", () => {
const registry = new ManagedVoiceClientRegistry();
const owner = Symbol("connection");
registry.register(
{ host: "127.0.0.1", voicePort: 9987 },
17,
owner,
" managed-client-uid= ",
);
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.has({ host: "192.168.1.10", voicePort: 20_000 }, 17),
).toBe(false);
});
it("keeps a shared managed UID until its last owner unregisters", () => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "203.0.113.4", voicePort: 9987 };
const first = Symbol("first connection");
const second = Symbol("second connection");
registry.register(scope, 18, first, "shared-client-uid=");
registry.register(scope, 19, second, "shared-client-uid=");
expect(registry.unregister(scope, 18, first, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(true);
expect(registry.unregister(scope, 19, second, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(false);
});
it("ignores missing or empty client UIDs", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "203.0.113.4", voicePort: 9987 },
19,
Symbol("connection"),
" ",
);
expect(registry.hasClientUid(undefined)).toBe(false);
expect(registry.hasClientUid(" ")).toBe(false);
});
it("uses an IPv6-safe scope key", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "[2001:0db8:0:0:0:0:0:1]", voicePort: 9987 },
9,
Symbol("connection"),
);
expect(
registry.has({ host: "2001:db8::1", voicePort: 9987 }, 9),
).toBe(true);
});
it("does not let a delayed old disconnect remove a replacement", () => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
const oldConnection = Symbol("old connection");
const newConnection = Symbol("new connection");
registry.register(scope, 12, oldConnection, "managed-client-uid=");
registry.register(scope, 12, newConnection, "managed-client-uid=");
// The old UID owner is removed, but the replacement still owns both the
// scoped id and the shared stable UID.
expect(
registry.unregister(scope, 12, oldConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(true);
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.unregister(scope, 12, newConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(false);
expect(registry.hasClientUid("managed-client-uid=")).toBe(false);
});
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
"ignores invalid client id %s",
(clientId) => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
const owner = Symbol("connection");
expect(registry.register(scope, clientId, owner)).toBe(false);
expect(registry.has(scope, clientId)).toBe(false);
expect(registry.unregister(scope, clientId, owner)).toBe(false);
},
);
it("has no shared module-level state between registry instances", () => {
const first = new ManagedVoiceClientRegistry();
const second = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
first.register(scope, 3, Symbol("connection"));
expect(first.has(scope, 3)).toBe(true);
expect(second.has(scope, 3)).toBe(false);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { isIP } from "node:net";
/** Identifies one TeamSpeak voice server. */
export interface ManagedVoiceClientScope {
host: string;
voicePort: number;
}
export interface NormalizedManagedVoiceClientScope {
readonly host: string;
readonly voicePort: number;
}
/**
* An opaque value identifying the connection that owns a client id.
*
* A fresh object or Symbol per connection is recommended. Value tokens are
* also supported for callers that already have a unique connection id.
*/
export type ManagedVoiceClientOwnerToken = object | string | number | symbol;
/**
* Normalize a TeamSpeak host for comparisons.
*
* DNS names are case-insensitive and may include a trailing root dot. IPv6
* literals may be supplied either bare or in URL-style brackets; valid IPv6
* addresses are also put into the canonical form produced by the URL parser.
*/
export function normalizeManagedVoiceHost(host: string): string {
let normalized = host.trim().toLowerCase().replace(/\.+$/, "");
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (isIP(normalized) === 6) {
// URL's host serializer compresses equivalent IPv6 spellings. `isIP`
// ensures interpolation cannot be interpreted as another URL component.
const serialized = new URL(`http://[${normalized}]/`).hostname;
return serialized.slice(1, -1);
}
return normalized;
}
/** Return a comparable scope, or null when the runtime input is unusable. */
export function normalizeManagedVoiceClientScope(
scope: ManagedVoiceClientScope,
): NormalizedManagedVoiceClientScope | null {
if (
!scope ||
typeof scope.host !== "string" ||
typeof scope.voicePort !== "number"
) {
return null;
}
const host = normalizeManagedVoiceHost(scope.host);
if (
host.length === 0 ||
!Number.isInteger(scope.voicePort) ||
scope.voicePort < 1 ||
scope.voicePort > 65_535
) {
return null;
}
return { host, voicePort: scope.voicePort };
}
function scopeKey(scope: ManagedVoiceClientScope): string | null {
const normalized = normalizeManagedVoiceClientScope(scope);
if (!normalized) return null;
// A serialized tuple stays unambiguous when host itself contains colons.
return JSON.stringify([normalized.host, normalized.voicePort]);
}
function validClientId(clientId: number): boolean {
return Number.isSafeInteger(clientId) && clientId > 0;
}
function normalizeClientUid(clientUid: string | undefined): string | null {
if (typeof clientUid !== "string") return null;
const normalized = clientUid.trim();
return normalized.length > 0 ? normalized : null;
}
/**
* Tracks voice client ids and stable TeamSpeak identities owned by bot
* connections in this process. The UID path survives DNS aliases, NAT,
* multiple NICs, and dual-stack endpoints; scoped ids remain a fallback when
* a sender has not yet appeared in the receiving client's view cache.
*
* This class intentionally has no module-level singleton. BotManager owns one
* instance and injects it into its BotInstances so separate managers remain
* isolated in tests and in the same process.
*/
export class ManagedVoiceClientRegistry {
private readonly clientsByScope = new Map<
string,
Map<number, ManagedVoiceClientOwnerToken>
>();
private readonly ownersByClientUid = new Map<
string,
Set<ManagedVoiceClientOwnerToken>
>();
/**
* Register (or replace) the connection that owns a client id and, when
* available, add its stable UID to the managed set.
* Returns false when the scope or client id is invalid.
*/
register(
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
let clients = this.clientsByScope.get(key);
if (!clients) {
clients = new Map();
this.clientsByScope.set(key, clients);
}
clients.set(clientId, ownerToken);
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
let owners = this.ownersByClientUid.get(normalizedUid);
if (!owners) {
owners = new Set();
this.ownersByClientUid.set(normalizedUid, owners);
}
owners.add(ownerToken);
}
return true;
}
/**
* Remove a client only if it is still owned by this connection.
*
* The ownership check prevents a delayed disconnect from an old connection
* deleting a newer connection that reused the same TeamSpeak client id.
*/
unregister(
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
let removed = false;
const clients = this.clientsByScope.get(key);
if (clients?.get(clientId) === ownerToken) {
clients.delete(clientId);
if (clients.size === 0) this.clientsByScope.delete(key);
removed = true;
}
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
const owners = this.ownersByClientUid.get(normalizedUid);
if (owners?.delete(ownerToken)) removed = true;
if (owners?.size === 0) this.ownersByClientUid.delete(normalizedUid);
}
return removed;
}
has(scope: ManagedVoiceClientScope, clientId: number): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
return this.clientsByScope.get(key)?.has(clientId) ?? false;
}
/** TeamSpeak client UIDs are stable across endpoint aliases and NAT paths. */
hasClientUid(clientUid: string | undefined): boolean {
const normalizedUid = normalizeClientUid(clientUid);
return normalizedUid
? (this.ownersByClientUid.get(normalizedUid)?.size ?? 0) > 0
: false;
}
}
+5
View File
@@ -15,6 +15,7 @@ import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -72,6 +73,7 @@ export interface CreateBotParams {
export class BotManager extends EventEmitter {
private bots = new Map<string, BotInstance>();
private readonly managedVoiceClients = new ManagedVoiceClientRegistry();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
@@ -161,6 +163,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
@@ -305,6 +308,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
@@ -363,6 +367,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
+295
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Client, generateIdentity } from "@honeybbq/teamspeak-client";
import { BotProfileManager } from "./profile.js";
import { TS6HttpQuery } from "../ts-protocol/http-query.js";
import type { TS3Client } from "../ts-protocol/client.js";
import type { QueuedSong } from "../audio/queue.js";
@@ -14,6 +16,9 @@ function makeMockTs(): TS3Client & {
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
getClientId: () => 17,
getChannelId: () => 5n,
execCommand: vi.fn().mockResolvedValue(undefined),
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
const chunks: Buffer[] = [];
@@ -145,3 +150,293 @@ 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.execCommand 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);
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function makeHttpProfile(partial: Partial<typeof cfgOff> = {}) {
const ts = makeMockTs() as any;
const state = { clid: 17, cid: 5n };
const descriptions = new Map<number, string>();
const http = new TS6HttpQuery({ host: "127.0.0.1", port: 10080 });
const request = vi.spyOn(http, "request").mockImplementation(async (_method, path, body) => {
if (path.includes("clientlist")) {
return { status: 200, body: { body: [{ clid: String(state.clid), cid: String(state.cid) }], status: { code: 0, message: "ok" } } };
}
if (path.includes("channeledit")) descriptions.set(Number(body!.cid), String(body!.channel_description));
return { status: 200, body: { status: { code: 0, message: "ok" } } };
});
ts.getHttpQuery = () => http;
ts.getClientId = () => state.clid;
ts.getChannelId = () => state.cid;
const logger: any = { child: () => logger, info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
const pm = new BotProfileManager(ts, logger, { ...cfgOff, ...partial }, "Bot");
return { pm, ts, state, http, request, descriptions, logger };
}
describe("BotProfileManager checked TS6 profile lifecycle", () => {
it("clears the old channel through HTTP Query when moved, even without full-client edit permission", async () => {
const { pm, ts, state, descriptions } = makeHttpProfile({ channelDescEnabled: true });
ts.execCommand.mockRejectedValue(new Error("insufficient client permissions"));
await pm.onSongChange(fakeSong);
expect(descriptions.get(5)).toContain("X - Y");
state.cid = 9n;
await pm.onChannelMoved(9n);
expect(descriptions.get(5)).toBe("");
expect(descriptions.get(9)).toContain("X - Y");
expect(ts.sendCommandNoWait).not.toHaveBeenCalled();
expect(ts.execCommand).not.toHaveBeenCalled();
});
it("discards an old client-list reply after reconnect", async () => {
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
state.cid = 0n;
const lookup = deferred<any>();
request.mockImplementationOnce(() => lookup.promise);
const update = pm.onSongChange(fakeSong);
await flush();
state.clid = 21;
state.cid = 9n;
pm.onConnect();
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
await update;
expect(request.mock.calls.filter((call) => call[1].includes("channeledit"))).toEqual([]);
await pm.onSongChange(null);
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
});
it("does not restore the previous remembered channel when a write completes after reconnect", async () => {
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
const write = deferred<any>();
request.mockImplementationOnce(() => write.promise);
const update = pm.onSongChange(fakeSong);
await flush();
state.clid = 21;
state.cid = 9n;
pm.onConnect();
write.resolve({ status: 200, body: { status: { code: 0, message: "ok" } } });
await update;
await pm.onSongChange(null);
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
});
it("discards a pending channel lookup when playback stops", async () => {
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
ts.getChannelId = () => 0n;
const lookup = deferred<any>();
request.mockImplementationOnce(() => lookup.promise);
const update = pm.onSongChange(fakeSong);
await flush();
await pm.onSongChange(null);
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
await update;
expect(descriptions.get(5)).toBe("");
});
it("discards a pending channel lookup when the bot is moved", async () => {
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
ts.getChannelId = () => 0n;
const lookup = deferred<any>();
request.mockImplementationOnce(() => lookup.promise);
const update = pm.onSongChange(fakeSong);
await flush();
await pm.onChannelMoved(9n);
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
await update;
expect(descriptions.has(5)).toBe(false);
expect(descriptions.get(9)).toContain("X - Y");
});
it("does not disable the new connection after an old write returns a permission failure", async () => {
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
const write = deferred<any>();
request.mockImplementationOnce(() => write.promise);
const update = pm.onSongChange(fakeSong);
await flush();
state.clid = 21;
state.cid = 9n;
pm.onConnect();
write.resolve({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
await update;
await pm.onSongChange(fakeSong);
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
});
it("resolves an unknown channel and sends raw newlines with one targeted description update", async () => {
const { pm, ts, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
ts.getChannelId = () => 0n;
state.cid = 5n;
await pm.onSongChange(fakeSong);
expect(request.mock.calls.filter((call) => call[1].includes("clientedit"))).toEqual([
["POST", "/1/clientedit?sid=1", { clid: 17, client_description: "X - Y [Z]" }],
]);
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 5, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
expect(ts.execCommand).not.toHaveBeenCalled();
});
it("does not resolve or write a disconnected client", async () => {
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
state.clid = 0;
state.cid = 0n;
await pm.onSongChange(fakeSong);
expect(request).not.toHaveBeenCalled();
});
it("reports HTTP lookup permission errors once and retries after reconnect", async () => {
const { pm, ts, request } = makeHttpProfile({ channelDescEnabled: true });
ts.getChannelId = () => 0n;
request.mockResolvedValue({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
await pm.onSongChange(fakeSong);
await pm.onSongChange(fakeSong);
expect(request).toHaveBeenCalledTimes(1);
pm.onConnect();
await pm.onSongChange(fakeSong);
expect(request).toHaveBeenCalledTimes(2);
});
it("checks self clientupdate permission responses and retries only after reconnect", async () => {
const { pm, ts, request, logger } = makeHttpProfile({ nicknameEnabled: true, awayStatusEnabled: true });
const client: any = new Client(generateIdentity(0), "127.0.0.1:9987", "Bot");
const commands: string[] = [];
client.handler.sendPacket = vi.fn((_type, data: Buffer) => {
const command = data.toString();
commands.push(command);
const returnCode = command.match(/return_code=(\d+)/)?.[1];
client.handler.onPacket({ typeFlagged: 2, data: Buffer.from(`error id=2568 msg=insufficient\\sclient\\spermissions${returnCode ? ` return_code=${returnCode}` : ""}`) });
});
ts.sendCommandNoWait.mockImplementation((command: string) => client.sendCommandNoWait(command));
ts.execCommand.mockImplementation((command: string) => client.execCommand(command));
await pm.onSongChange(null);
await pm.onSongChange(null);
expect(commands).toHaveLength(1);
expect(commands[0]).toMatch(/^clientupdate client_nickname=Bot client_away=1 client_away_message=等待播放 return_code=\d+$/);
expect(request).not.toHaveBeenCalled();
expect(logger.info.mock.calls.some((call: any[]) => call[1] === "Client properties updated (nickname + away)")).toBe(false);
pm.onConnect();
await pm.onSongChange(null);
expect(commands).toHaveLength(2);
});
});
+230 -72
View File
@@ -13,6 +13,13 @@ const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
interface ProfileUpdateContext {
generation: number;
channelGeneration: number;
clientId: number;
httpQuery: ReturnType<TS3Client["getHttpQuery"]>;
}
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
@@ -32,6 +39,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 = {
@@ -50,6 +64,8 @@ export class BotProfileManager {
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
/** Channel moves supersede channel writes without cancelling avatar work. */
private channelGeneration = 0;
constructor(
tsClient: TS3Client,
@@ -65,6 +81,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.
*
@@ -98,20 +128,25 @@ export class BotProfileManager {
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
this.channelGeneration++;
this.currentSong = song;
const context = this.createUpdateContext();
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
if (!this.isCurrentUpdate(context)) return;
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 2. Checked clientupdate sent by the visible client itself.
await this.updateClientProperties(song, context);
if (!this.isCurrentUpdate(context)) return;
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
await this.updateDescription(song, context);
if (!this.isCurrentUpdate(context)) return;
// 4. Checked channel description update.
await this.updateChannelDescription(song, context);
if (!this.isCurrentUpdate(context)) return;
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
@@ -119,7 +154,10 @@ export class BotProfileManager {
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.channelGeneration++;
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 +174,32 @@ 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;
this.channelGeneration++;
const context = this.createUpdateContext();
const song = this.currentSong;
try {
if (oldChannelId !== null) {
if (!await this.writeChannelDescription(oldChannelId, "", context)) return;
this.channelDescCid = null;
}
} catch (err) {
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
return;
}
if (song) {
await this.updateChannelDescription(song, context, newChannelId);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
@@ -238,53 +302,56 @@ export class BotProfileManager {
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
private async updateDescription(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
if (!this.isCurrentUpdate(context)) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
const clid = context.clientId;
if (clid <= 0) return;
const httpQuery = context.httpQuery;
if (httpQuery) {
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
// be misreported as success.
const result = await httpQuery.clientUpdate({ client_description: text });
this.logger.info({ status: result.status }, "Description updated");
// IMPORTANT:
// clientUpdate() would modify the HTTP Query/serveradmin client.
// Explicitly edit the real visible music client instead.
const result = await httpQuery.clientEdit(clid, {
client_description: text,
});
if (!this.isCurrentUpdate(context)) return;
this.logger.info(
{ status: result.status, clid },
"Description updated",
);
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
this.logger.info("Description updated");
if (!this.isCurrentUpdate(context)) return;
this.logger.info({ clid }, "Description updated");
}
} catch (err) {
this.handleFeatureError("description", err);
if (this.isCurrentUpdate(context)) this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*
* Values are collected as raw strings/numbers. The TS6 HTTP path
* forwards them as JSON (the server expects real spaces, not `\s`);
* the TS3 wire path escapes them on the fly. Previously the code
* escaped upfront and then split the escaped string to build the
* JSON body, so TS6 received literal backslashes and silently
* rejected the update.
* and away status together. The full client sends this command on both
* TS3 and TS6, with a return code so permission failures are observable.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
private async updateClientProperties(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
if (!this.isCurrentUpdate(context) || context.clientId <= 0) return;
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
@@ -305,39 +372,38 @@ export class BotProfileManager {
rawProps.client_away = 0;
} else {
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
rawProps.client_away_message = "等待播放";
}
}
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
const result = await httpQuery.clientUpdate(rawProps);
this.logger.info(
{ status: result.status, props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
} else {
// TS3 wire protocol: escape string values inline.
// sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
const parts = Object.entries(rawProps).map(([k, v]) =>
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
);
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
this.logger.info(
{ props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
}
// clientupdate modifies whichever connection sends the command.
// Therefore it must be sent by the real full client, NOT HTTP Query.
const parts = Object.entries(rawProps).map(([key, value]) =>
typeof value === "string"
? `${key}=${escapeTS3(value)}`
: `${key}=${value}`,
);
await this.withTimeout(
this.tsClient.execCommand(`clientupdate ${parts.join(" ")}`),
5000,
);
if (!this.isCurrentUpdate(context)) return;
this.logger.info(
{
clid: context.clientId,
props: Object.keys(rawProps),
},
"Client properties updated (nickname + away)",
);
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
if (!this.isCurrentUpdate(context)) return;
if (rawProps.client_nickname !== undefined) this.handleFeatureError("nickname", err);
if (rawProps.client_away !== undefined) this.handleFeatureError("awayStatus", err);
}
}
@@ -386,33 +452,106 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
private async updateChannelDescription(
song: QueuedSong | null,
context: ProfileUpdateContext,
targetChannelId?: bigint,
): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
if (!this.isCurrentChannelUpdate(context) || context.clientId <= 0) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
// A stop already knows which channel to clear if a write succeeded.
// Avoid a needless client-list lookup that could prevent that cleanup.
let channelId = !song && this.channelDescCid !== null
? this.channelDescCid
: targetChannelId ?? this.tsClient.getChannelId();
// TS6 full-client may report channelID() as 0 even after the
// visible music client has already joined a channel.
// Fall back to HTTP Query and resolve our real clid -> cid.
if (channelId === 0n) {
const httpQuery = context.httpQuery;
const clid = context.clientId;
if (httpQuery && clid > 0) {
const result = await httpQuery.clientList();
if (!this.isCurrentChannelUpdate(context)) return;
const payload = result.body as {
body?: Array<Record<string, string>>;
};
const me = payload?.body?.find(
(client) => Number(client.clid) === clid,
);
if (me?.cid) {
channelId = BigInt(me.cid);
this.logger.info(
{
clid,
cid: channelId.toString(),
},
"Resolved channel ID via HTTP Query",
);
}
}
}
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
if (channelId <= 0n) return;
if (await this.writeChannelDescription(channelId, "", context)) this.channelDescCid = null;
return;
}
if (channelId <= 0n) return;
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
`♪ 正在播放: ${song.name} - ${song.artist}`,
`专辑: ${song.album}`,
`平台: ${song.platform}`,
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
// HTTP Query uses a normal JSON string, so use real newlines here.
const desc = lines.join("\n");
if (await this.writeChannelDescription(channelId, desc, context)) this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
}
}
/** Both move cleanup and ordinary writes use the same checked transport. */
private async writeChannelDescription(
channelId: bigint,
description: string,
context: ProfileUpdateContext,
): Promise<boolean> {
if (!this.isCurrentChannelUpdate(context)) return false;
let status: number | undefined;
if (context.httpQuery) {
const result = await context.httpQuery.channelEdit(Number(channelId), {
channel_description: description,
});
status = result.status;
} else {
await this.withTimeout(
this.tsClient.execCommand(
`channeledit cid=${channelId} channel_description=${escapeTS3(description)}`,
),
5000,
);
}
if (!this.isCurrentChannelUpdate(context)) return false;
this.logger.info(
{ status, cid: channelId.toString() },
description ? "Channel description updated" : "Channel description cleared",
);
return true;
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
@@ -425,6 +564,25 @@ export class BotProfileManager {
// --- Helpers ---
private createUpdateContext(): ProfileUpdateContext {
return {
generation: this.generation,
channelGeneration: this.channelGeneration,
clientId: this.tsClient.getClientId(),
httpQuery: this.tsClient.getHttpQuery(),
};
}
private isCurrentUpdate(context: ProfileUpdateContext): boolean {
return context.generation === this.generation &&
context.clientId === this.tsClient.getClientId() &&
context.httpQuery === this.tsClient.getHttpQuery();
}
private isCurrentChannelUpdate(context: ProfileUpdateContext): boolean {
return this.isCurrentUpdate(context) && context.channelGeneration === this.channelGeneration;
}
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
+116 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
@@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => {
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
// Issue #139: `!play id <id>` matches the "<command> <subcommand> <arg>"
// shape of every other command. The colon form stays supported — users have
// it in their chat scrollback and in older docs.
it("parses the space-separated id form", () => {
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id 185868.")).toEqual({ id: "185868", platform: null });
});
it("does not mistake a word merely starting with 'id' for an id reference", () => {
expect(parseSongRef("idol")).toBeNull();
expect(parseSongRef("identity 185868")).toBeNull();
expect(parseSongRef("id")).toBeNull();
expect(parseSongRef("id:")).toBeNull();
// Two remaining tokens are a search phrase, not an id.
expect(parseSongRef("id die for you")).toBeNull();
});
// Without a colon, "id" is just a word — "ID 4" and "ID Bruno" are real track
// titles. The space form therefore only claims tokens that could actually be
// an id; everything else stays a search term.
it("only treats the space form as an id when the token looks like one", () => {
expect(parseSongRef("id Bruno")).toBeNull();
expect(parseSongRef("id Marshmello")).toBeNull();
expect(parseSongRef("id 4ever")).toBeNull();
// …while every real id shape is still accepted.
expect(parseSongRef("id 4")).toEqual({ id: "4", platform: null }); // numeric
expect(parseSongRef("id BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: null });
expect(parseSongRef("id 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); // QQ mid
expect(parseSongRef("id a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toEqual({
id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
platform: null,
}); // Jellyfin GUID / Kugou hash
});
it("keeps the colon form unrestricted, so a short or odd id still works", () => {
expect(parseSongRef("id:Bruno")).toEqual({ id: "Bruno", platform: null });
expect(parseSongRef("id: 4ever")).toEqual({ id: "4ever", platform: null });
});
it("does not let the space form swallow a pasted URL", () => {
// `id <url>` used to fall through to the URL branches; it still must.
expect(parseSongRef("id https://music.163.com/song?id=185868")).toEqual({
id: "185868",
platform: "netease",
});
expect(parseSongRef("id https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({
id: "004Z8Ihr0JIu5s",
platform: "qq",
});
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
@@ -67,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
expect(parseSelectionIndex("")).toBeNull();
});
});
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
expect(parsePlaylistRef("华语经典")).toBeNull();
expect(parsePlaylistRef("2829883282")).toBeNull();
expect(parsePlaylistRef("")).toBeNull();
});
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
});
it("does not mistake a NetEase userid= for the playlist id", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
});
it("parses QQ Music playlist URLs", () => {
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
});
it("parses YouTube playlist URLs by their list= id", () => {
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
});
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
});
it("finds the link inside an app's share text", () => {
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
});
});
describe("findShareShortLink (#160)", () => {
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
});
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
expect(findShareShortLink("华语经典")).toBeNull();
});
});
describe("resolveShareLink (#160)", () => {
it("returns the redirect target", async () => {
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
});
it("returns null when there is no redirect or the request fails", async () => {
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
});
});
+114 -6
View File
@@ -1,3 +1,5 @@
import axios from "axios";
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
@@ -18,9 +20,22 @@ export interface SongRef {
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Could this token plausibly BE an id on a supported platform?
* - NetEase / Kugou numeric ids → all digits
* - BiliBili → BV + 8-12 alphanumerics
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
* → 11+ chars from the id alphabet
* Deliberately conservative: anything rejected here just stays an ordinary
* search term, which is what it almost certainly was.
*/
function looksLikeSongId(token: string): boolean {
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - `id <id>` / `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
@@ -30,11 +45,25 @@ export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// Explicit id — platform decided by the command's flags/default. The
// separator is a colon or plain whitespace, so `id <id>` matches the
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
// from a chat paste ("id:12345." / "id:12345)") — no supported id
// (numeric / BVID / mid) ends in those.
//
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
// has to keep resolving as a URL. So the space form only claims tokens that
// could actually be an id; anything else falls through to the URL branches
// below and ultimately to a plain search.
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
if (idPrefix) {
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
return { id, platform: null };
}
}
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
@@ -71,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
export interface PlaylistRef {
id: string;
platform: "netease" | "qq" | "youtube";
}
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
function stripUrlBBCode(text: string): string {
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
}
/**
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
* share text. The platform comes from the URL, so a QQ link works without
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
* the caller handles as before.
*/
export function parsePlaylistRef(raw: string): PlaylistRef | null {
const q = stripUrlBBCode(raw ?? "").trim();
if (!q) return null;
if (/music\.163\.com/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
if (/y\.qq\.com/i.test(q)) {
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
if (/youtube\.com|youtu\.be/i.test(q)) {
const m = /[?&]list=([\w-]+)/.exec(q);
if (m) return { id: m[1], platform: "youtube" };
}
return null;
}
/**
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
* short link — what the phone apps copy. Only these hosts are recognized so
* the bot never fetches an arbitrary user-supplied URL.
*/
export function findShareShortLink(raw: string): string | null {
const q = stripUrlBBCode(raw ?? "");
const m =
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
return m ? m[0] : null;
}
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
const redirectGet: RedirectGet = async (url) => {
const res = await axios.get(url, {
maxRedirects: 0,
timeout: 5000,
validateStatus: () => true,
responseType: "stream",
});
res.data?.destroy?.();
const location = res.headers.location;
return { status: res.status, location: typeof location === "string" ? location : undefined };
};
/** Follow a share short link one hop. Returns the target URL, or null. */
export async function resolveShareLink(
url: string,
get: RedirectGet = redirectGet,
): Promise<string | null> {
try {
const { status, location } = await get(url);
if (status < 300 || status >= 400 || !location) return null;
return new URL(location, url).toString();
} catch {
return null;
}
}
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { VoiceDuckingController } from "./voice-ducking.js";
function makeHarness(
enabled = true,
volumePercent = 30,
timing = { attackMs: 50, holdMs: 100, releaseMs: 200 },
) {
let now = 0;
const setDuckingGain = vi.fn<(gain: number, rampMs?: number) => void>();
const controller = new VoiceDuckingController(
{ setDuckingGain },
{ enabled, volumePercent },
{ timing, now: () => now },
);
const advance = (milliseconds: number) => {
now += milliseconds;
vi.advanceTimersByTime(milliseconds);
};
return { controller, setDuckingGain, advance };
}
describe("VoiceDuckingController", () => {
afterEach(() => {
vi.useRealTimers();
});
it("is inert while disabled", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness(false);
controller.handleVoiceActivity(12);
advance(1_000);
expect(setDuckingGain).not.toHaveBeenCalled();
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
});
it("attacks once, refreshes the packet deadline, then releases", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(12);
expect(setDuckingGain).toHaveBeenCalledWith(0.3, 50);
advance(60);
controller.handleVoiceActivity(12);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
// The original t=100 sweep observes the refreshed t=160 deadline.
advance(40);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
advance(60);
expect(controller.isDucking()).toBe(false);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("stays ducked until the last overlapping speaker expires", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(1);
advance(50);
controller.handleVoiceActivity(2);
advance(50);
expect(controller.activeSpeakerCount()).toBe(1);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
advance(50);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("removes a client immediately on leave without disturbing other speakers", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
controller.handleVoiceActivity(1);
controller.handleVoiceActivity(2);
controller.removeSpeaker(1);
expect(controller.isDucking()).toBe(true);
expect(controller.activeSpeakerCount()).toBe(1);
controller.removeSpeaker(2);
expect(controller.isDucking()).toBe(false);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("retargets a live duck and smoothly restores when disabled", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
controller.handleVoiceActivity(7);
controller.updateSettings({ enabled: true, volumePercent: 45 });
expect(setDuckingGain).toHaveBeenLastCalledWith(0.45, 50);
controller.updateSettings({ enabled: false, volumePercent: 45 });
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("attacks again when speech resumes during the release window", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(7);
advance(100);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
advance(50);
controller.handleVoiceActivity(7);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenLastCalledWith(0.3, 50);
});
it("invalidates an old expiry callback after reset", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(8);
controller.reset(true);
const callsAfterReset = setDuckingGain.mock.calls.length;
advance(1_000);
expect(setDuckingGain).toHaveBeenCalledTimes(callsAfterReset);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
});
it("rejects invalid client ids and supports an immediate lifecycle reset", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
for (const id of [0, -1, 1.5, Number.NaN]) {
controller.handleVoiceActivity(id);
}
expect(setDuckingGain).not.toHaveBeenCalled();
controller.handleVoiceActivity(8);
controller.reset(true);
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
});
});
+183
View File
@@ -0,0 +1,183 @@
export interface VoiceDuckingSettings {
enabled: boolean;
volumePercent: number;
}
export interface VoiceDuckingGainTarget {
setDuckingGain(gain: number, rampMs?: number): void;
}
export interface VoiceDuckingTiming {
attackMs: number;
holdMs: number;
releaseMs: number;
}
export const DEFAULT_VOICE_DUCKING_TIMING: Readonly<VoiceDuckingTiming> = {
attackMs: 50,
holdMs: 700,
releaseMs: 500,
};
interface VoiceDuckingControllerOptions {
timing?: Partial<VoiceDuckingTiming>;
now?: () => number;
}
function nonNegativeFinite(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, value)
: fallback;
}
function normalizeSettings(settings: VoiceDuckingSettings): VoiceDuckingSettings {
return {
enabled: settings.enabled === true,
volumePercent:
typeof settings.volumePercent === "number" && Number.isFinite(settings.volumePercent)
? Math.max(0, Math.min(100, settings.volumePercent))
: 30,
};
}
/**
* Converts the stream of incoming TeamSpeak voice packets into a stable
* ducking envelope. TeamSpeak's full-client protocol exposes voice packets,
* but not an explicit "stopped talking" event, so a speaker remains active
* for a short hold period after their most recent packet.
*
* Only one timeout is live at a time. Repeated ~20 ms voice packets update a
* deadline in the map instead of constantly destroying/recreating timers.
*/
export class VoiceDuckingController {
private settings: VoiceDuckingSettings;
private readonly timing: VoiceDuckingTiming;
private readonly now: () => number;
private readonly activeUntil = new Map<number, number>();
private expiryTimer: ReturnType<typeof setTimeout> | null = null;
private timerDueAt = Number.POSITIVE_INFINITY;
private timerGeneration = 0;
private ducking = false;
constructor(
private readonly target: VoiceDuckingGainTarget,
initialSettings: VoiceDuckingSettings,
options: VoiceDuckingControllerOptions = {},
) {
this.settings = normalizeSettings(initialSettings);
this.timing = {
attackMs: nonNegativeFinite(options.timing?.attackMs, DEFAULT_VOICE_DUCKING_TIMING.attackMs),
holdMs: nonNegativeFinite(options.timing?.holdMs, DEFAULT_VOICE_DUCKING_TIMING.holdMs),
releaseMs: nonNegativeFinite(options.timing?.releaseMs, DEFAULT_VOICE_DUCKING_TIMING.releaseMs),
};
this.now = options.now ?? (() => performance.now());
}
handleVoiceActivity(clientId: number): void {
if (!this.settings.enabled || !Number.isInteger(clientId) || clientId <= 0) return;
const now = this.now();
this.activeUntil.set(clientId, now + this.timing.holdMs);
if (!this.ducking) {
this.ducking = true;
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
}
this.scheduleNextSweep(now);
}
removeSpeaker(clientId: number): void {
if (!this.activeUntil.delete(clientId)) return;
if (this.activeUntil.size === 0) {
this.cancelTimer();
this.release();
}
}
updateSettings(settings: VoiceDuckingSettings): void {
const previous = this.settings;
this.settings = normalizeSettings(settings);
if (!this.settings.enabled) {
this.activeUntil.clear();
this.cancelTimer();
this.release();
return;
}
if (
previous.volumePercent !== this.settings.volumePercent &&
this.ducking
) {
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
}
}
/** Clear all activity. Disconnects use an immediate reset; disabling the
* feature uses updateSettings(), which returns smoothly over releaseMs. */
reset(immediate = true): void {
this.activeUntil.clear();
this.cancelTimer();
this.ducking = false;
this.target.setDuckingGain(1, immediate ? 0 : this.timing.releaseMs);
}
isDucking(): boolean {
return this.ducking;
}
activeSpeakerCount(): number {
return this.activeUntil.size;
}
private scheduleNextSweep(now = this.now()): void {
if (this.activeUntil.size === 0) return;
let nextDueAt = Number.POSITIVE_INFINITY;
for (const deadline of this.activeUntil.values()) {
if (deadline < nextDueAt) nextDueAt = deadline;
}
// Keeping an earlier timer is intentional. When it fires it will observe
// the refreshed deadline and schedule the remaining delay, avoiding timer
// churn on every incoming packet.
if (this.expiryTimer && this.timerDueAt <= nextDueAt) return;
this.cancelTimer();
const generation = ++this.timerGeneration;
this.timerDueAt = nextDueAt;
this.expiryTimer = setTimeout(() => {
if (generation !== this.timerGeneration) return;
this.expiryTimer = null;
this.timerDueAt = Number.POSITIVE_INFINITY;
this.sweepExpiredSpeakers();
}, Math.max(0, nextDueAt - now));
}
private sweepExpiredSpeakers(): void {
const now = this.now();
for (const [clientId, deadline] of this.activeUntil) {
if (deadline <= now) this.activeUntil.delete(clientId);
}
if (this.activeUntil.size > 0) {
this.scheduleNextSweep(now);
} else {
this.release();
}
}
private release(): void {
if (!this.ducking) return;
this.ducking = false;
this.target.setDuckingGain(1, this.timing.releaseMs);
}
private cancelTimer(): void {
this.timerGeneration++;
if (this.expiryTimer) clearTimeout(this.expiryTimer);
this.expiryTimer = null;
this.timerDueAt = Number.POSITIVE_INFINITY;
}
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import {
createApiKeyStore,
type ApiKeyStore,
MAX_API_KEYS_PER_USER,
API_KEY_TOUCH_INTERVAL_MS,
} from "./api-keys.js";
function sha256(key: string) {
return createHash("sha256").update(key).digest("hex");
}
describe("ApiKeyStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let keys: ApiKeyStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
keys = createApiKeyStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("create returns a tsmb_-prefixed raw key whose sha256 is stored, never the plaintext", () => {
const created = keys.create(userId, "ci");
expect(created).not.toBeNull();
expect(created!.rawKey).toMatch(/^tsmb_[A-Za-z0-9_-]{40,}$/);
const row = botDb.db.prepare("SELECT keyHash, keyPrefix FROM api_keys").get() as {
keyHash: string;
keyPrefix: string;
};
expect(row.keyHash).toBe(sha256(created!.rawKey));
expect(row.keyHash).not.toContain(created!.rawKey);
expect(created!.key.keyPrefix).toBe(created!.rawKey.slice(0, 12));
});
it("validateAndTouch resolves the owner user for a fresh key", () => {
const { rawKey } = keys.create(userId, "ci")!;
const result = keys.validateAndTouch(rawKey);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null for an unknown or empty key", () => {
keys.create(userId, "ci");
expect(keys.validateAndTouch("tsmb_not-a-real-key")).toBeNull();
expect(keys.validateAndTouch("")).toBeNull();
});
it("delete removes the key so it no longer validates", () => {
const { key, rawKey } = keys.create(userId, "ci")!;
expect(keys.delete(key.id, userId)).toBe(true);
expect(keys.validateAndTouch(rawKey)).toBeNull();
});
it("delete with userId refuses to remove another user's key", async () => {
const { key } = keys.create(userId, "ci")!;
const other = await users.createUser("bob", "pw-bob", "member");
expect(keys.delete(key.id, other.id)).toBe(false);
expect(keys.delete(key.id)).toBe(true);
});
it("keys of a deleted user stop validating", async () => {
const { rawKey } = keys.create(userId, "ci")!;
users.deleteUser(userId);
expect(keys.validateAndTouch(rawKey)).toBeNull();
});
it("enforces the per-user key cap", () => {
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
expect(keys.create(userId, `key-${i}`)).not.toBeNull();
}
expect(keys.create(userId, "one-too-many")).toBeNull();
expect(keys.listForUser(userId)).toHaveLength(MAX_API_KEYS_PER_USER);
});
it("touches lastUsedAt at most once per interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { rawKey } = keys.create(userId, "ci")!;
keys.validateAndTouch(rawKey);
const first = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + 30_000);
keys.validateAndTouch(rawKey);
const second = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
expect(second).toBe(first);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + API_KEY_TOUCH_INTERVAL_MS + 1000);
keys.validateAndTouch(rawKey);
const third = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
expect(third).toBeGreaterThan(first);
});
it("deleteAllForUser clears every key of that user", async () => {
keys.create(userId, "a");
keys.create(userId, "b");
const other = await users.createUser("bob", "pw-bob", "member");
keys.create(other.id, "c");
keys.deleteAllForUser(userId);
expect(keys.listForUser(userId)).toHaveLength(0);
expect(keys.listForUser(other.id)).toHaveLength(1);
});
it("listAll exposes usernames for admin views", async () => {
keys.create(userId, "ci");
const other = await users.createUser("bob", "pw-bob", "member");
keys.create(other.id, "deploy");
const all = keys.listAll();
expect(all).toHaveLength(2);
expect(all.map((k) => k.username).sort()).toEqual(["alice", "bob"]);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
export const MAX_API_KEYS_PER_USER = 20;
export const API_KEY_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
/** Visible prefix stored for list views, e.g. "tsmb_a1b2c3d4". */
export const API_KEY_PREFIX_LENGTH = 12;
export interface ApiKeyRow {
id: string;
userId: string;
name: string;
keyPrefix: string;
createdAt: number;
lastUsedAt: number | null;
}
export interface ApiKeyWithUser extends ApiKeyRow {
username: string;
}
export interface ApiKeyValidation {
keyId: string;
userId: string;
username: string;
role: "admin" | "member";
}
export interface CreatedApiKey {
key: ApiKeyRow;
/** Plaintext key — returned exactly once, at creation time. */
rawKey: string;
}
export interface ApiKeyStore {
/** Returns null when the per-user key cap is reached. */
create(userId: string, name: string): CreatedApiKey | null;
findById(id: string): ApiKeyWithUser | null;
listForUser(userId: string): ApiKeyRow[];
listAll(): ApiKeyWithUser[];
/** With userId, only deletes a key owned by that user. */
delete(id: string, userId?: string): boolean;
deleteAllForUser(userId: string): void;
validateAndTouch(rawKey: string): ApiKeyValidation | null;
}
function hashKey(rawKey: string): string {
return createHash("sha256").update(rawKey).digest("hex");
}
export function createApiKeyStore(db: Database.Database): ApiKeyStore {
const insertStmt = db.prepare(
"INSERT INTO api_keys (id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt) VALUES (?, ?, ?, ?, ?, ?, NULL)"
);
const selectForUserStmt = db.prepare(
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE userId = ? ORDER BY createdAt DESC"
);
const selectAllStmt = db.prepare(
`SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username
FROM api_keys k INNER JOIN users u ON u.id = k.userId
ORDER BY k.createdAt DESC`
);
const selectByIdStmt = db.prepare(
`SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username
FROM api_keys k INNER JOIN users u ON u.id = k.userId
WHERE k.id = ?`
);
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?");
const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE userId = ?");
const validateStmt = db.prepare(
`SELECT k.id, k.userId, k.lastUsedAt, u.username, u.role
FROM api_keys k INNER JOIN users u ON u.id = k.userId
WHERE k.keyHash = ?`
);
const touchStmt = db.prepare("UPDATE api_keys SET lastUsedAt = ? WHERE id = ?");
return {
create(userId, name) {
const count = (countForUserStmt.get(userId) as { n: number }).n;
if (count >= MAX_API_KEYS_PER_USER) {
return null;
}
const rawKey = `tsmb_${randomBytes(32).toString("base64url")}`;
const row: ApiKeyRow = {
id: randomUUID(),
userId,
name,
keyPrefix: rawKey.slice(0, API_KEY_PREFIX_LENGTH),
createdAt: Date.now(),
lastUsedAt: null,
};
insertStmt.run(row.id, row.userId, row.name, hashKey(rawKey), row.keyPrefix, row.createdAt);
return { key: row, rawKey };
},
findById(id) {
return (selectByIdStmt.get(id) as ApiKeyWithUser | undefined) ?? null;
},
listForUser(userId) {
return selectForUserStmt.all(userId) as ApiKeyRow[];
},
listAll() {
return selectAllStmt.all() as ApiKeyWithUser[];
},
delete(id, userId) {
const row = selectByIdStmt.get(id) as ApiKeyRow | undefined;
if (!row) return false;
if (userId !== undefined && row.userId !== userId) return false;
deleteStmt.run(id);
return true;
},
deleteAllForUser(userId) {
deleteAllForUserStmt.run(userId);
},
validateAndTouch(rawKey) {
if (!rawKey) return null;
const row = validateStmt.get(hashKey(rawKey)) as
| { id: string; userId: string; lastUsedAt: number | null; username: string; role: string }
| undefined;
if (!row) return null;
// The reserved guest principal must never authenticate via API keys;
// guest access is session-only by design.
if (row.role !== "admin" && row.role !== "member") return null;
const now = Date.now();
if (row.lastUsedAt === null || now - row.lastUsedAt > API_KEY_TOUCH_INTERVAL_MS) {
touchStmt.run(now, row.id);
}
return { keyId: row.id, userId: row.userId, username: row.username, role: row.role };
},
};
}
+3 -1
View File
@@ -7,7 +7,9 @@ export type AuditAction =
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
| "user.permissions_changed"
| "api_key.created"
| "api_key.deleted";
export interface AuditEntry {
id: number;
+206
View File
@@ -48,6 +48,79 @@ describe("config", () => {
expect(config).toEqual(getDefaultConfig());
});
it("defaults voice ducking to disabled at 30 percent", () => {
expect(getDefaultConfig().voiceDucking).toEqual({
enabled: false,
volumePercent: 30,
});
});
it("fills voiceDucking defaults for legacy and partial configs", () => {
const dir = makeTmpDir();
const legacyPath = join(dir, "legacy.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4000 }));
expect(loadConfig(legacyPath).voiceDucking).toEqual({
enabled: false,
volumePercent: 30,
});
const partialPath = join(dir, "partial.json");
writeFileSync(partialPath, JSON.stringify({ voiceDucking: { enabled: true } }));
expect(loadConfig(partialPath).voiceDucking).toEqual({
enabled: true,
volumePercent: 30,
});
});
it("loadConfig preserves valid voiceDucking values including range endpoints", () => {
const dir = makeTmpDir();
for (const volumePercent of [0, 37.5, 100]) {
const path = join(dir, `voice-ducking-${volumePercent}.json`);
writeFileSync(
path,
JSON.stringify({ voiceDucking: { enabled: true, volumePercent } }),
);
expect(loadConfig(path).voiceDucking).toEqual({ enabled: true, volumePercent });
}
});
it("loadConfig strictly sanitizes malformed voiceDucking values", () => {
const dir = makeTmpDir();
const malformed: Array<{ name: string; json: string }> = [
{ name: "null-block", json: JSON.stringify({ voiceDucking: null }) },
{ name: "array-block", json: JSON.stringify({ voiceDucking: [true, 10] }) },
{ name: "string-block", json: JSON.stringify({ voiceDucking: "on" }) },
{
name: "wrong-types",
json: JSON.stringify({ voiceDucking: { enabled: "yes", volumePercent: "25" } }),
},
{
name: "below-range",
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: -1 } }),
},
{
name: "above-range",
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: 101 } }),
},
// JSON.parse("1e309") produces Infinity, exercising the finite-number guard.
{
name: "non-finite",
json: '{"voiceDucking":{"enabled":true,"volumePercent":1e309}}',
},
];
for (const testCase of malformed) {
const path = join(dir, `${testCase.name}.json`);
writeFileSync(path, testCase.json);
const loaded = loadConfig(path).voiceDucking;
if (testCase.name === "below-range" || testCase.name === "above-range" || testCase.name === "non-finite") {
expect(loaded).toEqual({ enabled: true, volumePercent: 30 });
} else {
expect(loaded).toEqual({ enabled: false, volumePercent: 30 });
}
}
});
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
const config = getDefaultConfig();
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
@@ -82,6 +155,61 @@ describe("config", () => {
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");
@@ -92,6 +220,60 @@ describe("config", () => {
expect(defaultPlatform(config)).toBe("jellyfin");
});
// ── audioQuality persistence (#125) ─────────────────────────────────────
it("defaults audioQuality to each provider's in-memory default", () => {
const config = getDefaultConfig();
expect(config.audioQuality).toEqual({
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
});
});
it("fills audioQuality defaults for a legacy config without the field", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
const config = loadConfig(path);
expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality);
});
it("round-trips a saved audioQuality through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const config = getDefaultConfig();
config.audioQuality = {
netease: "lossless",
qq: "flac",
bilibili: "high",
kugou: "flac",
jellyfin: "320",
};
saveConfig(path, config);
const loaded = loadConfig(path);
expect(loaded.audioQuality).toEqual(config.audioQuality);
});
it("coerces missing / non-string audioQuality fields to defaults", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid.
writeFileSync(
path,
JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }),
);
const config = loadConfig(path);
expect(config.audioQuality).toEqual({
netease: "lossless",
qq: "exhigh", // blank → default
bilibili: "high", // non-string → default
kugou: "128", // missing → default
jellyfin: "192",
});
});
it("creates config file on save", () => {
const dir = makeTmpDir();
const path = join(dir, "sub", "config.json");
@@ -494,4 +676,28 @@ describe("loadConfig error handling", () => {
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);
});
});
+141 -7
View File
@@ -38,6 +38,28 @@ export interface JellyfinConfig {
userId: string;
}
/**
* Per-provider audio quality (音质), persisted so a restart keeps the user's
* choice instead of resetting each provider to its in-memory default (#125).
* The values are the same strings the WebUI/REST `POST /api/music/quality`
* endpoint sends and each provider's setQuality() accepts; on startup they are
* replayed onto the (shared, process-wide) providers. Providers ignore/normalize
* unknown values, so a stale/hand-edited entry can never break playback.
*/
export interface AudioQualityConfig {
netease: string;
qq: string;
bilibili: string;
kugou: string;
jellyfin: string;
}
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
@@ -61,15 +83,24 @@ export function isProviderEnabled(config: BotConfig, platform: string): boolean
}
/**
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls:
* 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.
* 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;
}
@@ -88,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"
@@ -102,6 +148,8 @@ export interface BotConfig {
guestMode: GuestModeConfig;
spotify: SpotifyConfig;
jellyfin: JellyfinConfig;
/** Persisted per-provider audio quality (音质), restored on startup (#125). */
audioQuality: AudioQualityConfig;
/**
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
* the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
@@ -110,6 +158,14 @@ export interface BotConfig {
* 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 {
@@ -128,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: {
@@ -162,7 +224,17 @@ export function getDefaultConfig(): BotConfig {
apiKey: "",
userId: "",
},
// Mirrors each provider's own in-memory default quality; overwritten on
// startup once the user has changed a quality (persisted via #125).
audioQuality: {
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
},
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
defaultPlatform: null,
};
}
@@ -317,6 +389,63 @@ export function loadConfig(path: string): BotConfig {
)
: 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,
@@ -324,7 +453,12 @@ export function loadConfig(path: string): BotConfig {
guestMode: gm,
spotify,
jellyfin,
audioQuality,
enabledProviders,
savedQueuesEnabled,
playKeepsQueue,
voiceDucking,
defaultPlatform: defaultPlatformPref,
};
}
}
+200 -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", () => {
@@ -131,6 +131,92 @@ describe("database", () => {
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
const inst = {
id: "bot-ps",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
// Fresh row → in-memory defaults.
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
// Volume and play mode persist independently.
botDb.saveVolume("bot-ps", 42);
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
botDb.savePlayMode("bot-ps", "rloop");
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
botDb.saveBotInstance({ ...inst, autoStart: true });
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
});
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
// No row → defaults.
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
botDb.saveBotInstance({
id: "bot-v",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
botDb.saveVolume("bot-v", 250);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
botDb.saveVolume("bot-v", -10);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
botDb.savePlayMode("bot-v", "bogus");
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
});
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
const p = join(dir, "legacy.db");
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
const legacy = createDatabase(p);
legacy.db.exec("DROP TABLE bot_instances");
legacy.db.exec(`CREATE TABLE bot_instances (
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
)`);
legacy.db
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
.run("legacy-bot");
legacy.close();
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
const reopened = createDatabase(p);
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
expect(cols).toContain("volume");
expect(cols).toContain("play_mode");
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
reopened.close();
rmSync(dir, { recursive: true, force: true });
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
@@ -153,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", () => {
@@ -179,3 +345,36 @@ describe("guest principal migration", () => {
rmSync(dir, { recursive: true, force: true });
});
});
describe("user music cookies (#164)", () => {
let botDb: BotDatabase;
const addUser = (id: string) =>
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run(id, id, "x", 0, 0, "member");
beforeEach(() => {
botDb = createDatabase(":memory:");
addUser("u1");
addUser("u2");
});
afterEach(() => botDb.close());
it("stores, overwrites and deletes a cookie per user and platform", () => {
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=b");
expect(botDb.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=b");
expect(botDb.getUserMusicCookie("u2", "netease")).toBeNull();
expect(botDb.getUserMusicCookie("u1", "qq")).toBeNull();
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(true);
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(false);
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
});
it("drops a user's cookies when the user is deleted", () => {
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
botDb.db.prepare("DELETE FROM users WHERE id = ?").run("u1");
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
});
});
+332
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;
@@ -55,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;
@@ -75,12 +135,28 @@ 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;
// Per-user music account cookies (#164).
getUserMusicCookie(userId: string, platform: string): string | null;
setUserMusicCookie(userId: string, platform: string, cookie: string): void;
deleteUserMusicCookie(userId: 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;
}
@@ -119,6 +195,15 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
// exactly as before until the user changes them.
if (!names.includes("volume")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
}
if (!names.includes("play_mode")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
@@ -161,6 +246,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
);
@@ -185,6 +272,18 @@ function initTables(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
name TEXT NOT NULL,
keyHash TEXT NOT NULL UNIQUE,
keyPrefix TEXT NOT NULL,
createdAt INTEGER NOT NULL,
lastUsedAt INTEGER,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_userId ON api_keys(userId);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
@@ -223,6 +322,39 @@ 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'))
);
-- A web user's own music-platform login (#164), used for their personal
-- FM instead of the bot's shared account. Secret: never sent to clients.
CREATE TABLE IF NOT EXISTS user_music_cookies (
userId TEXT NOT NULL,
platform TEXT NOT NULL,
cookie TEXT NOT NULL,
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (userId, platform),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
`);
}
@@ -321,6 +453,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 = ?`);
@@ -342,6 +480,78 @@ export function createDatabase(dbPath: string): BotDatabase {
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectUserMusicCookie = db.prepare(
`SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`,
);
const upsertUserMusicCookie = db.prepare(`
INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?)
ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now')
`);
const deleteUserMusicCookieStmt = db.prepare(
`DELETE FROM user_music_cookies WHERE userId = ? 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,
@@ -406,6 +616,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;
@@ -432,6 +672,98 @@ export function createDatabase(dbPath: string): BotDatabase {
return row !== undefined;
},
getUserMusicCookie(userId, platform) {
const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined;
return row?.cookie ?? null;
},
setUserMusicCookie(userId, platform, cookie) {
upsertUserMusicCookie.run(userId, platform, cookie);
},
deleteUserMusicCookie(userId, platform) {
return deleteUserMusicCookieStmt.run(userId, platform).changes > 0;
},
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();
},
+9
View File
@@ -100,6 +100,15 @@ async function main() {
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
// Restore the persisted per-provider audio quality (#125) onto the shared,
// process-wide providers so a restart keeps the user's choice. setQuality()
// normalizes/ignores unknown values, so a stale entry can never break playback.
neteaseProvider.setQuality(config.audioQuality.netease);
qqProvider.setQuality(config.audioQuality.qq);
bilibiliProvider.setQuality(config.audioQuality.bilibili);
kugouProvider.setQuality(config.audioQuality.kugou);
jellyfinProvider.setQuality(config.audioQuality.jellyfin);
const permissions = createPermissionStore(db.db);
// Single process-wide Spotify authorization (one Premium account for Stage 3).
+43
View File
@@ -0,0 +1,43 @@
import type { Server } from "node:http";
import { closeEmbeddedApi, getSafeApiStartupError, startEmbeddedApi, type ApiChildMessage, type ApiProvider } from "./api-server-runtime.js";
const providerArg = process.argv[2];
const port = Number(process.argv[3]);
if ((providerArg !== "netease" && providerArg !== "qq") || !Number.isInteger(port) || port < 1 || port > 65535 || !process.send) process.exit(1);
const provider = providerArg as ApiProvider;
let server: Server | null = null;
let stopping = false;
function shutdown(exitCode = 0): void {
if (stopping) return;
stopping = true;
// Also covers a legacy auto-start listener and an import still in flight.
const deadline = setTimeout(() => process.exit(exitCode), 1000);
closeEmbeddedApi(server).finally(() => { clearTimeout(deadline); process.exit(exitCode); });
}
function fail(error: unknown): void {
if (stopping) return;
const message: ApiChildMessage = { type: "error", provider, port, ...getSafeApiStartupError(error) };
if (!process.connected) { shutdown(1); return; }
try { process.send!(message, () => shutdown(1)); }
catch { shutdown(1); }
}
process.on("message", (message: unknown) => {
if (message && typeof message === "object" && (message as { type?: unknown }).type === "stop") shutdown();
});
process.on("disconnect", () => shutdown());
process.on("SIGTERM", () => shutdown());
process.on("SIGINT", () => shutdown());
process.on("uncaughtException", fail);
process.on("unhandledRejection", fail);
startEmbeddedApi(provider, port).then((runtime) => {
server = runtime.server;
if (stopping || !process.connected) { shutdown(); return; }
server?.on("error", fail);
const message: ApiChildMessage = { type: "ready", provider, port };
try { process.send!(message, (error) => { if (error) shutdown(1); }); }
catch { shutdown(1); }
}).catch(fail);
+86
View File
@@ -0,0 +1,86 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeEmbeddedApi, getSafeApiStartupError, startEmbeddedApi } from "./api-server-runtime.js";
const state = vi.hoisted(() => ({ serveNcmApi: vi.fn(), qqExport: null as any, portFree: true }));
vi.mock("NeteaseCloudMusicApi", () => ({ server: { serveNcmApi: state.serveNcmApi } }));
vi.mock("@sansenjian/qq-music-api", () => ({ get default() { return state.qqExport; } }));
vi.mock("node:net", async () => {
const { EventEmitter } = await import("node:events");
return { default: { createServer: () => {
const probe = new EventEmitter() as EventEmitter & { close(done: () => void): void; listen(): void };
probe.close = (done) => queueMicrotask(done);
probe.listen = () => queueMicrotask(() => probe.emit(state.portFree ? "listening" : "error"));
return probe;
} } };
});
class FakeServer extends EventEmitter {
listening = false;
close = vi.fn((done: () => void) => { this.listening = false; queueMicrotask(done); return this; });
closeAllConnections = vi.fn();
}
describe("embedded API runtime", () => {
let server: FakeServer;
let listen: ReturnType<typeof vi.fn>;
let previousPort: string | undefined;
beforeEach(() => {
previousPort = process.env.PORT;
server = new FakeServer();
state.portFree = true;
state.serveNcmApi.mockReset();
state.serveNcmApi.mockResolvedValue({ server });
listen = vi.fn(() => { queueMicrotask(() => { server.listening = true; server.emit("listening"); }); return server; });
state.qqExport = { listen };
});
afterEach(() => { if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort; });
it("binds NetEase to loopback/configured port without version checks and waits for listening", async () => {
let ready = false;
const starting = startEmbeddedApi("netease", 39218).then((result) => { ready = true; return result; });
await vi.waitFor(() => expect(server.listenerCount("listening")).toBe(1));
expect(state.serveNcmApi).toHaveBeenCalledWith({ port: 39218, host: "127.0.0.1", checkVersion: false });
expect(ready).toBe(false);
server.listening = true; server.emit("listening");
expect((await starting).server).toBe(server);
});
it("closes the HTTP server returned by NetEase rather than the Express app", async () => {
server.listening = true;
const runtime = await startEmbeddedApi("netease", 39218);
await closeEmbeddedApi(runtime.server);
expect(server.close).toHaveBeenCalledTimes(1);
expect(server.closeAllConnections).toHaveBeenCalledTimes(1);
});
it("rejects failed listening and cleans up the startup handle", async () => {
const starting = startEmbeddedApi("netease", 39218);
const rejected = expect(starting).rejects.toMatchObject({ code: "EADDRINUSE" });
await vi.waitFor(() => expect(server.listenerCount("error")).toBe(1));
server.emit("error", Object.assign(new Error("bind failure"), { code: "EADDRINUSE" }));
await rejected;
expect(server.close).toHaveBeenCalledTimes(1);
});
it("binds QQ to its configured loopback port and restores injected PORT", async () => {
process.env.PORT = "39999";
expect((await startEmbeddedApi("qq", 39217)).server).toBe(server);
expect(listen).toHaveBeenCalledWith(39217, "127.0.0.1");
expect(process.env.PORT).toBe("39999");
});
it("restores an absent PORT and supports the legacy nested export", async () => {
delete process.env.PORT; state.qqExport = { default: { listen } };
await startEmbeddedApi("qq", 39217);
expect(process.env.PORT).toBeUndefined();
expect(listen).toHaveBeenCalledWith(39217, "127.0.0.1");
});
it("reuses a legacy module that auto-started on import without a duplicate listen", async () => {
state.portFree = false;
expect(await startEmbeddedApi("qq", 39217)).toEqual({ server: null });
expect(listen).not.toHaveBeenCalled();
});
it("never reflects arbitrary startup message, stack or code values", () => {
expect(getSafeApiStartupError({ message: "synthetic-credential", stack: "synthetic-credential", code: "synthetic-credential" })).toEqual({ category: "startup" });
expect(getSafeApiStartupError({ code: "ERR_REQUIRE_ESM", message: "synthetic-credential" })).toEqual({ category: "esm", code: "ERR_REQUIRE_ESM" });
expect(getSafeApiStartupError({ code: "EBADENGINE" })).toEqual({ category: "node-engine", code: "EBADENGINE" });
expect(getSafeApiStartupError({ code: "EADDRINUSE" })).toEqual({ category: "port-in-use", code: "EADDRINUSE" });
});
});
+92
View File
@@ -0,0 +1,92 @@
import net from "node:net";
import type { Server } from "node:http";
export type ApiProvider = "netease" | "qq";
export type ApiStartupCategory = "esm" | "node-engine" | "port-in-use" | "startup" | "timeout" | "cancelled";
export interface SafeApiStartupError { category: ApiStartupCategory; code?: string }
export type ApiChildMessage =
| { type: "ready"; provider: ApiProvider; port: number }
| ({ type: "error"; provider: ApiProvider; port: number } & SafeApiStartupError);
const SAFE_ERROR_CODES = new Set(["ERR_REQUIRE_ESM", "EBADENGINE", "EADDRINUSE", "EACCES", "ENOENT", "MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND"]);
export function safeApiErrorCode(code: unknown): string | undefined {
return typeof code === "string" && SAFE_ERROR_CODES.has(code) ? code : undefined;
}
/** Classification may inspect a message locally, but IPC never contains it. */
export function getSafeApiStartupError(err: unknown): SafeApiStartupError {
const error = (err ?? {}) as { code?: unknown; message?: unknown };
const code = safeApiErrorCode(error.code);
const message = typeof error.message === "string" ? error.message : "";
let category: ApiStartupCategory = "startup";
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(message)) category = "esm";
else if (code === "EBADENGINE" || /Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(message)) category = "node-engine";
else if (code === "EADDRINUSE") category = "port-in-use";
return code ? { category, code } : { category };
}
export function isApiPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => server.close(() => resolve(false)));
server.once("listening", () => server.close(() => resolve(true)));
server.listen(port, "127.0.0.1");
});
}
function waitForListening(server: Server): Promise<void> {
if (server.listening) return Promise.resolve();
return new Promise((resolve, reject) => {
const ready = () => { cleanup(); resolve(); };
const failed = (error: Error) => { cleanup(); reject(error); };
const cleanup = () => { server.off("listening", ready); server.off("error", failed); };
server.once("listening", ready);
server.once("error", failed);
});
}
export async function closeEmbeddedApi(server: Server | null): Promise<void> {
if (!server) return;
await new Promise<void>((resolve) => {
try {
server.close(() => resolve());
server.closeAllConnections?.();
} catch { resolve(); }
});
}
/** Only call in the isolated child: these dependencies write raw request URLs
* and response cookies directly to console, outside the bot's logger. */
export async function startEmbeddedApi(provider: ApiProvider, port: number): Promise<{ server: Server | null }> {
let server: Server | null = null;
try {
if (provider === "netease") {
const imported = await import("NeteaseCloudMusicApi") as any;
const api = imported.server ?? imported.default?.server;
const app = await api.serveNcmApi({ port, host: "127.0.0.1", checkVersion: false });
server = app.server;
if (!server) throw new Error("NetEase API did not expose its HTTP server");
} else {
const previousPort = process.env.PORT;
process.env.PORT = String(port);
let imported: any;
try { imported = await import("@sansenjian/qq-music-api"); }
finally {
if (previousPort === undefined) delete process.env.PORT;
else process.env.PORT = previousPort;
}
const candidate = imported.default ?? imported;
const app = typeof candidate.listen === "function" ? candidate : candidate.default;
if (!app || typeof app.listen !== "function") throw new Error("QQ API did not expose a Koa app");
// Historical packages listened during import. Their listener remains
// owned by this child and closes when the child exits.
if (!(await isApiPortFree(port))) return { server: null };
server = app.listen(port, "127.0.0.1");
}
await waitForListening(server!);
return { server };
} catch (error) {
await closeEmbeddedApi(server);
throw error;
}
}
+170 -112
View File
@@ -1,133 +1,191 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } 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 };
const state = vi.hoisted(() => ({ fork: vi.fn(), probes: [] as EventEmitter[], probeAutomatically: true, portFree: true, directImports: 0 }));
vi.mock("node:child_process", () => ({ fork: state.fork }));
vi.mock("node:net", async () => {
const { EventEmitter } = await import("node:events");
return { default: { createServer: () => {
const probe = new EventEmitter() as EventEmitter & { close(done: () => void): void; listen(): void };
probe.close = (done) => queueMicrotask(done);
probe.listen = () => {
state.probes.push(probe);
if (state.probeAutomatically) queueMicrotask(() => probe.emit(state.portFree ? "listening" : "error"));
};
return probe;
} } };
});
vi.mock("@sansenjian/qq-music-api", () => {
state.directImports++;
return { default: { listen: () => { throw new Error("sidecar imported in parent"); } } };
});
vi.mock("NeteaseCloudMusicApi", () => {
state.directImports++;
return { server: { serveNcmApi: () => { throw new Error("sidecar imported in parent"); } } };
});
class FakeChild extends EventEmitter {
connected = true;
exitOnStop = true;
send = vi.fn((message: { type: string }) => {
if (message.type === "stop" && this.exitOnStop) queueMicrotask(() => this.finish(0, null));
return true;
});
kill = vi.fn((signal: string = "SIGTERM") => { queueMicrotask(() => this.finish(null, signal)); return true; });
finish(code: number | null, signal: string | null) { this.connected = false; this.emit("exit", code, signal); }
}
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("retains ESM diagnostics by code and message", () => {
expect(describeQqApiStartupError({ code: "ERR_REQUIRE_ESM" })).toMatch(/~2\.4\.0/);
expect(describeQqApiStartupError(new Error("require() of ES Module is unsupported"))).toMatch(/ERR_REQUIRE_ESM/);
});
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();
it("retains engine diagnostics and ignores unrelated failures", () => {
expect(describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"))).toMatch(/Node >=20\.17/);
expect(describeQqApiStartupError(new Error("EADDRINUSE"))).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;
describe("embedded API child lifecycle", () => {
let children: FakeChild[];
let logger: Logger;
let manager: ReturnType<typeof createApiServerManager>;
let automaticReady: boolean;
const options = { neteasePort: 39218, qqMusicPort: 39217, neteaseEnabled: true, qqEnabled: true };
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
beforeEach(() => {
mockState.listenCalls = [];
vi.useRealTimers(); children = []; automaticReady = true;
state.probes = []; state.portFree = true; state.probeAutomatically = true; state.fork.mockReset();
state.fork.mockImplementation((_entry: string, args: string[]) => {
const child = new FakeChild(); children.push(child);
if (automaticReady) queueMicrotask(() => child.emit("message", { type: "ready", provider: args[0], port: Number(args[1]) }));
return child as unknown as ChildProcess;
});
logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger;
manager = createApiServerManager(options, logger);
});
afterEach(async () => { manager.stop(); await flush(); vi.useRealTimers(); });
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
);
it("isolates both APIs with ignored stdio, configured ports and IPC", async () => {
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" }]);
expect(state.fork).toHaveBeenCalledTimes(2);
expect(state.fork.mock.calls.map((call) => call[1])).toEqual([["netease", "39218"], ["qq", "39217"]]);
for (const call of state.fork.mock.calls) {
expect(String(call[0])).toMatch(/api-server-child\.ts$/);
expect(call[2].stdio).toEqual(["ignore", "ignore", "ignore", "ipc"]);
expect(call[2].execArgv).not.toContain("--eval");
expect(call[2].execArgv).not.toContain("--input-type=module");
}
expect(state.directImports).toBe(0);
expect(manager.getNeteaseBaseUrl()).toBe("http://127.0.0.1:39218");
expect(manager.getQQMusicBaseUrl()).toBe("http://127.0.0.1:39217");
});
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
);
it("preserves provider gating and externally bound port reuse", async () => {
manager = createApiServerManager({ ...options, neteaseEnabled: false, qqEnabled: false }, logger);
await manager.start(); expect(state.probes).toHaveLength(0); expect(state.fork).not.toHaveBeenCalled();
state.portFree = false;
manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
await manager.start(); expect(state.fork).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, expect.stringContaining("reusing"));
});
it("inherits tsx loader arguments without unrelated parent runner flags", async () => {
const previous = process.execArgv;
process.execArgv = ["--require", "C:\\app\\node_modules\\tsx\\dist\\preflight.cjs", "--import", "file:///app/node_modules/tsx/dist/loader.mjs", "--eval", "synthetic-evaluation", "--conditions", "vitest", "--input-type=module", "--inspect"];
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;
}
expect(state.fork.mock.calls[0][2].execArgv).toEqual(process.execArgv.slice(0, 4));
} finally { process.execArgv = 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;
it("does not duplicate concurrent or repeated starts", async () => {
await Promise.all([manager.start(), manager.start()]); await manager.start();
expect(state.fork).toHaveBeenCalledTimes(2);
});
it("fences a stop during pending port preflight", async () => {
state.probeAutomatically = false;
const starting = manager.start(); await flush(); manager.stop();
state.probes[0].emit("listening"); await starting;
expect(state.fork).not.toHaveBeenCalled();
});
it("cancels a pending handshake and ignores its late ready", async () => {
automaticReady = false;
const starting = manager.start(); await flush(); expect(children).toHaveLength(1);
manager.stop(); children[0].emit("message", { type: "ready", provider: "netease", port: 39218 }); await starting;
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
expect(state.fork).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalledWith({ port: 39218 }, "NetEase Cloud Music API started");
});
it("waits for a cancelled preflight to release its probe before restart", async () => {
state.probeAutomatically = false;
const first = manager.start(); await flush(); manager.stop();
const restarting = manager.start(); await flush();
expect(state.probes).toHaveLength(1);
state.probeAutomatically = true; state.probes[0].emit("listening");
await Promise.all([first, restarting]);
expect(state.fork).toHaveBeenCalledTimes(2);
});
it("waits for old children to exit before restart", async () => {
await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
const restarting = manager.start(); await flush(); expect(state.fork).toHaveBeenCalledTimes(2);
children.slice(0, 2).forEach((child) => child.finish(0, null)); await restarting;
expect(state.fork).toHaveBeenCalledTimes(4);
});
it("reports unexpected post-ready exits with safe fields", async () => {
await manager.start(); children[1].finish(7, "SIGTERM");
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, code: 7, signal: "SIGTERM" }, expect.stringContaining("exited unexpectedly"));
});
it("retains static QQ diagnostics and discards arbitrary IPC fields", async () => {
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
const starting = manager.start(); await flush();
children[0].emit("message", { type: "error", provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM", message: "synthetic-credential", stack: "synthetic-credential" }); await starting;
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM" }, expect.stringContaining("ERR_REQUIRE_ESM"));
expect(JSON.stringify([...(logger.error as ReturnType<typeof vi.fn>).mock.calls, ...(logger.warn as ReturnType<typeof vi.fn>).mock.calls])).not.toContain("synthetic-credential");
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
});
it("ignores a ready message for a different provider or port", async () => {
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
const starting = manager.start(); await flush();
children[0].emit("message", { type: "ready", provider: "netease", port: 39217 });
children[0].emit("message", { type: "ready", provider: "qq", port: 39999 });
await flush();
expect(logger.info).not.toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
children[0].emit("message", { type: "ready", provider: "qq", port: 39217 }); await starting;
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
});
it("cleans up a failed fork that closes without an exit event", async () => {
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
const starting = manager.start(); await flush();
children[0].emit("error", Object.assign(new Error("synthetic-credential"), { code: "ENOENT" }));
children[0].emit("close", null, null); await starting;
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "startup", code: "ENOENT" }, expect.stringContaining("start"));
automaticReady = true; await manager.start();
expect(state.fork).toHaveBeenCalledTimes(2);
});
it("times out and terminates a silent child", async () => {
vi.useFakeTimers(); automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
const starting = manager.start(); await flush(); await vi.advanceTimersByTimeAsync(30000); await starting;
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "timeout" }, expect.stringContaining("start"));
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
});
it("forces shutdown if a child ignores stop", async () => {
vi.useFakeTimers(); await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
await vi.advanceTimersByTimeAsync(2000);
expect(children.every((child) => child.kill.mock.calls.length > 0)).toBe(true);
expect(logger.error).not.toHaveBeenCalled();
});
it("escalates to SIGKILL if stop and SIGTERM are ignored", async () => {
vi.useFakeTimers(); await manager.start();
for (const child of children) {
child.exitOnStop = false;
child.kill.mockImplementation((signal: string = "SIGTERM") => {
if (signal === "SIGKILL") queueMicrotask(() => child.finish(null, signal));
return true;
});
}
manager.stop(); await vi.advanceTimersByTimeAsync(2000);
expect(children.every((child) => child.kill.mock.calls.some(([signal]) => signal === "SIGKILL"))).toBe(true);
expect(logger.error).not.toHaveBeenCalled();
});
});
+160 -178
View File
@@ -1,16 +1,13 @@
import net from "node:net";
import { fork, type ChildProcess } from "node:child_process";
import type { Logger } from "../logger.js";
import type { Server } from "node:http";
import { getSafeApiStartupError, isApiPortFree, safeApiErrorCode, type ApiProvider, type SafeApiStartupError } from "./api-server-runtime.js";
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 {
start(): Promise<void>;
stop(): void;
@@ -18,196 +15,181 @@ 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";
}
const { category } = getSafeApiStartupError(err);
if (category === "esm") 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 (category === "node-engine") 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();
server.once("error", () => {
server.close(() => resolve(false));
});
server.once("listening", () => {
server.close(() => resolve(true));
});
server.listen(port, "127.0.0.1");
});
/** Carry only tsx loader arguments into a source child. CLI evaluation,
* inspector and test-runner flags have unrelated meanings in a fork. */
function childExecArgv(source: boolean): string[] {
if (!source) return [];
const args: string[] = [];
for (let i = 0; i < process.execArgv.length; i++) {
const arg = process.execArgv[i];
if (arg === "--import" || arg === "--require" || arg === "-r") {
const value = process.execArgv[++i];
if (value && (value === "tsx" || /[/\\]tsx[/\\]/.test(value))) args.push(arg, value);
} else if (arg.startsWith("--import=") && (arg === "--import=tsx" || /[/\\]tsx[/\\]/.test(arg))) args.push(arg);
}
return args.length ? args : ["--import", "tsx"];
}
export function createApiServerManager(
options: ApiServerOptions,
logger: Logger
): ApiServerManager {
let neteaseServer: Server | null = null;
let qqMusicServer: Server | null = null;
class StartupFailure extends Error {
constructor(readonly details: SafeApiStartupError) { super("Embedded music API startup failed"); }
}
interface ManagedChild {
child: ChildProcess;
stop(): Promise<void>;
}
const STARTUP_TIMEOUT_MS = 15000;
const ERROR_CATEGORIES = new Set(["esm", "node-engine", "port-in-use", "startup"]);
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
const children = new Map<ApiProvider, ManagedChild>();
let generation = 0;
let starting: Promise<void> | null = null;
let stopping: Promise<void> = Promise.resolve();
function launch(provider: ApiProvider, port: number, launchGeneration: number): Promise<void> {
const source = import.meta.url.endsWith(".ts");
const entry = new URL(source ? "./api-server-child.ts" : "./api-server-child.js", import.meta.url);
const child = fork(entry, [provider, String(port)], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
execArgv: childExecArgv(source),
});
let ready = false;
let expectedExit = false;
let settled = false;
let hasExited = false;
let startupTimer: ReturnType<typeof setTimeout>;
let terminateTimer: ReturnType<typeof setTimeout> | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let resolveExit!: () => void;
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
let resolveStart!: () => void;
let rejectStart!: (error: StartupFailure) => void;
const started = new Promise<void>((resolve, reject) => { resolveStart = resolve; rejectStart = reject; });
const settle = (error?: SafeApiStartupError) => {
if (settled) return;
settled = true;
clearTimeout(startupTimer);
if (error) rejectStart(new StartupFailure(error)); else resolveStart();
};
const record: ManagedChild = {
child,
stop() {
if (expectedExit) return exited;
expectedExit = true;
settle({ category: "cancelled" });
if (children.get(provider) === record) children.delete(provider);
stopping = Promise.all([stopping, exited]).then(() => {});
if (hasExited) return exited;
try {
if (child.connected) child.send({ type: "stop" }, (error) => { if (error) child.kill("SIGTERM"); });
else child.kill("SIGTERM");
} catch { child.kill("SIGTERM"); }
terminateTimer = setTimeout(() => child.kill("SIGTERM"), 1000);
killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
terminateTimer.unref(); killTimer.unref();
return exited;
},
};
children.set(provider, record);
child.on("message", (message: unknown) => {
if (!message || typeof message !== "object" || expectedExit || launchGeneration !== generation) return;
const data = message as Record<string, unknown>;
if (data.provider !== provider || data.port !== port) return;
if (data.type === "ready") { ready = true; settle(); }
else if (data.type === "error" && typeof data.category === "string" && ERROR_CATEGORIES.has(data.category)) {
const code = safeApiErrorCode(data.code);
const details: SafeApiStartupError = { category: data.category as SafeApiStartupError["category"], ...(code ? { code } : {}) };
if (!ready) settle(details);
else logger.error({ provider, port, ...details }, "Embedded music API reported a runtime failure");
void record.stop();
}
});
child.on("error", (error) => {
if (expectedExit) return;
const details = getSafeApiStartupError(error);
if (!ready) settle(details);
else logger.error({ provider, port, ...details }, "Embedded music API child failed");
void record.stop();
});
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (hasExited) return;
hasExited = true;
clearTimeout(startupTimer); clearTimeout(terminateTimer); clearTimeout(killTimer);
if (children.get(provider) === record) children.delete(provider);
if (!expectedExit) {
if (ready) logger.error({ provider, port, code, signal }, "Embedded music API exited unexpectedly");
else settle({ category: "startup" });
}
resolveExit();
};
child.once("exit", onExit);
// A failed fork emits close without exit.
child.once("close", onExit);
startupTimer = setTimeout(() => { settle({ category: "timeout" }); void record.stop(); }, STARTUP_TIMEOUT_MS);
return started;
}
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
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");
start(): Promise<void> {
if (starting) return starting;
const startGeneration = generation;
const run = async () => {
await stopping;
if (startGeneration !== generation) return;
if (options.neteaseEnabled === false && options.qqEnabled === false) {
logger.info("NetEase/QQ providers disabled — embedded music API servers not started"); return;
}
}
// 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) {
logger.info(
{ port: options.qqMusicPort },
"QQ Music API port already in use — reusing existing instance"
);
} else {
// 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;
logger.info("Starting embedded music API servers...");
const providers: Array<{ provider: ApiProvider; port: number; enabled: boolean; name: string }> = [
{ provider: "netease", port: options.neteasePort, enabled: options.neteaseEnabled !== false, name: "NetEase Cloud Music" },
{ provider: "qq", port: options.qqMusicPort, enabled: options.qqEnabled !== false, name: "QQ Music" },
];
for (const { provider, port, enabled, name } of providers) {
if (startGeneration !== generation) return;
if (!enabled || children.has(provider)) continue;
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
// older: module itself may be the Koa app
const candidate = qqModule.default ?? qqModule;
const koaApp = typeof candidate.listen === "function"
? candidate
: candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") {
// 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"
);
} 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"
);
const free = await isApiPortFree(port);
if (startGeneration !== generation) return;
if (!free) {
logger.info({ port }, `${provider === "netease" ? "NetEase" : "QQ Music"} API port already in use — reusing existing instance`);
continue;
}
} else {
logger.warn("QQ Music API module does not expose a Koa app");
await launch(provider, port, startGeneration);
if (startGeneration !== generation) return;
logger.info({ port }, `${name} API started`);
} catch (error) {
if (startGeneration !== generation) return;
const details = error instanceof StartupFailure ? error.details : getSafeApiStartupError(error);
if (details.category === "cancelled") return;
const hint = provider === "qq" ? describeQqApiStartupError(details.category === "esm" ? { code: "ERR_REQUIRE_ESM" } : details.category === "node-engine" ? { code: "EBADENGINE" } : {}) : null;
logger.error({ provider, port, ...details }, hint ? `QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${port} is down.` : `Failed to start ${name} API`);
}
}
} catch (err) {
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"
);
}
}
};
const promise = run();
starting = promise;
void promise.finally(() => { if (starting === promise) starting = null; });
return promise;
},
stop(): void {
generation++;
const pendingStart = starting;
starting = null;
logger.info("Stopping music API servers");
if (neteaseServer && typeof (neteaseServer as any).close === "function") {
(neteaseServer as any).close();
}
neteaseServer = null;
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
(qqMusicServer as any).close();
}
qqMusicServer = null;
},
getNeteaseBaseUrl(): string {
return neteaseBaseUrl;
},
getQQMusicBaseUrl(): string {
return qqMusicBaseUrl;
const retiring = [...children.values()];
children.clear();
// A cancelled preflight still owns a temporary listening socket until
// its callback closes it. Restart must wait for that work as well.
stopping = Promise.all([stopping, pendingStart, ...retiring.map((record) => record.stop())]).then(() => {});
},
getNeteaseBaseUrl: () => `http://127.0.0.1:${options.neteasePort}`,
getQQMusicBaseUrl: () => `http://127.0.0.1:${options.qqMusicPort}`,
};
}
+167 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
@@ -37,3 +37,169 @@ describe("BiliBiliProvider.search pagination", () => {
expect(searchParams(get).page).toBe("1");
});
});
describe("BiliBiliProvider multi-P support", () => {
it("parseBilibiliId extracts bvid and page correctly", async () => {
const { parseBilibiliId } = await import("./bilibili.js");
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
bvid: "BV1yxHQeYEuE",
page: 5,
});
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
});
function mockViewProvider(viewData: any, playUrlData?: any) {
const p = new BiliBiliProvider();
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
if (url === "/x/web-interface/view") {
return Promise.resolve({ data: { data: viewData } });
}
if (url === "/x/player/playurl") {
return Promise.resolve({ data: { data: playUrlData ?? {} } });
}
return Promise.resolve({ data: {} });
});
(p as any).buvidInitialized = true;
(p as any).api = { get };
return { p, get };
}
const multiPViewData = {
bvid: "BV1multiP",
title: "测试多P教程",
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
duration: 300, // 总时长 300 秒 (120 + 180)
owner: { name: "UP主测试" },
pages: [
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
],
};
const singlePViewData = {
bvid: "BV1singleP",
title: "测试单P视频",
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
duration: 200,
owner: { name: "UP主测试" },
pages: [
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
],
};
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
const { p } = mockViewProvider(singlePViewData);
const song = await p.getSongDetail("BV1singleP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1singleP");
expect(song!.name).toBe("测试单P视频");
expect(song!.duration).toBe(200);
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=1");
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP?p=2");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=2");
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
expect(song!.duration).toBe(180); // P2 独立时长
expect(song!.platform).toBe("bilibili");
});
it("getVideoParts returns all parts with duration and cid", async () => {
const { p } = mockViewProvider(multiPViewData);
const partsResult = await p.getVideoParts("BV1multiP");
expect(partsResult).not.toBeNull();
expect(partsResult!.bvid).toBe("BV1multiP");
expect(partsResult!.title).toBe("测试多P教程");
expect(partsResult!.parts).toHaveLength(2);
expect(partsResult!.parts[0]).toEqual({
part: 1,
cid: 10001,
title: "第一讲 入门",
duration: 120,
});
expect(partsResult!.parts[1]).toEqual({
part: 2,
cid: 10002,
title: "第二讲 进阶",
duration: 180,
});
});
it("getSongUrl requests playurl with correct cid for specific part", async () => {
const playUrlResponse = {
dash: {
audio: [
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
],
},
};
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
const result = await p.getSongUrl("BV1multiP?p=2");
expect(result).not.toBeNull();
expect(result!.url).toBe("http://audio.320k.test");
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
expect(playurlCall).toBeTruthy();
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
describe("pickStableAudioUrl (#161 long streams dying mid-play)", () => {
const pcdn = "https://xy1x2x3x4xy.mcdn.bilivideo.cn:4483/upgcxcode/1/2/3/3-1-30280.m4s?e=x&deadline=1";
const szbdyd = "https://cn-hk-eq-01-01.szbdyd.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
const upos = "https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
const upos2 = "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
it("prefers an upos/cos mirror over a PCDN baseUrl", () => {
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd, upos] })).toBe(upos);
});
it("keeps the baseUrl when it is already a stable host", () => {
expect(pickStableAudioUrl({ baseUrl: upos, backupUrl: [upos2] })).toBe(upos);
});
it("accepts the snake_case field names", () => {
expect(pickStableAudioUrl({ base_url: pcdn, backup_url: [upos2] })).toBe(upos2);
});
it("falls back to the baseUrl when every candidate is PCDN", () => {
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd] })).toBe(pcdn);
});
it("returns undefined when there is no url at all", () => {
expect(pickStableAudioUrl({})).toBeUndefined();
});
it("getSongUrl returns the stable mirror of the best stream", async () => {
const p = new BiliBiliProvider();
(p as any).cidCache.set("BV1abc", 42);
(p as any).api = {
get: vi.fn().mockResolvedValue({
data: { data: { dash: { audio: [
{ bandwidth: 64000, baseUrl: "https://upos-sz-mirrorcos.bilivideo.com/low.m4s" },
{ bandwidth: 320000, baseUrl: pcdn, backupUrl: [upos] },
] } } },
}),
};
expect((await p.getSongUrl("BV1abc"))?.url).toBe(upos);
});
});
+138 -12
View File
@@ -27,6 +27,69 @@ 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[];
}
/**
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.szbdyd.com). Their
* sessions get cut mid-file, which kills long streams partway (#89, #161),
* and a reconnect to the same host rarely recovers.
*/
const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i;
/**
* Pick the audio URL least likely to die mid-stream: the first upos/cos
* mirror among baseUrl + backupUrl, else the baseUrl as before.
*/
export function pickStableAudioUrl(stream: {
baseUrl?: string;
base_url?: string;
backupUrl?: string[];
backup_url?: string[];
}): string | undefined {
const primary = stream.baseUrl ?? stream.base_url;
const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter(
(u): u is string => typeof u === "string" && u.length > 0,
);
const stable = candidates.find((u) => {
try {
return !BILI_PCDN_HOST.test(new URL(u).hostname);
} catch {
return false;
}
});
return stable ?? primary;
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
const str = (songId ?? "").trim();
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
if (!bvMatch) {
return { bvid: str, page: 1 };
}
const bvid = bvMatch[0];
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
const page = pageStr ? parseInt(pageStr, 10) : 1;
return { bvid, page: Math.max(1, page) };
}
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -192,18 +255,41 @@ export class BiliBiliProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
const { bvid, page } = parseBilibiliId(songId);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
params: { bvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
// Cache cid for later audio URL fetching
if (data.pages?.[0]?.cid) {
this.cidCache.set(songId, data.pages[0].cid);
const pages = data.pages ?? [];
// 缓存所有分P的 cid 映射
for (const p of pages) {
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(bvid, pages[0].cid);
}
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
// 若为多P视频,返回对应分P的名称与独立时长
if (pages.length > 1 && targetPage) {
const partTitle = targetPage.part && targetPage.part !== data.title
? `${data.title} - P${targetPage.page} ${targetPage.part}`
: `${data.title} (P${targetPage.page})`;
return {
id: `${bvid}?p=${targetPage.page}`,
name: partTitle,
artist: data.owner?.name ?? "",
album: "",
duration: targetPage.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
}
return {
@@ -211,7 +297,7 @@ export class BiliBiliProvider implements MusicProvider {
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
duration: targetPage?.duration ?? data.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
@@ -220,9 +306,47 @@ export class BiliBiliProvider implements MusicProvider {
}
}
/** 获取视频所有分P列表 */
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
const { bvid: cleanBvid } = parseBilibiliId(bvid);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: cleanBvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
const pages = data.pages ?? [];
for (const p of pages) {
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(cleanBvid, pages[0].cid);
}
return {
bvid: cleanBvid,
title: data.title ?? "",
coverUrl: this.normalizeCover(data.pic ?? ""),
artist: data.owner?.name ?? "",
parts: pages.map((p: any) => ({
part: p.page,
cid: p.cid,
title: p.part ?? `P${p.page}`,
duration: p.duration ?? 0,
})),
};
} catch {
return null;
}
}
/** Get CID for a bvid, using cache when available */
private async getCid(bvid: string): Promise<number | null> {
const cached = this.cidCache.get(bvid);
private async getCid(bvid: string, page = 1): Promise<number | null> {
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
if (cached) return cached;
// Limit cache size to prevent unbounded growth
@@ -231,20 +355,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,
@@ -258,7 +384,7 @@ export class BiliBiliProvider implements MusicProvider {
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
);
const biliUrl = best.baseUrl ?? best.base_url;
const biliUrl = pickStableAudioUrl(best);
return biliUrl ? { url: biliUrl } : null;
} catch {
return null;
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
// teardown is unaffected by the simulated lock on *.mp4.
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* #149: when the audio track is extracted successfully but the source video
* cannot be deleted (Windows keeps files locked briefly — rmSync with
* force:true still throws EBUSY/EPERM), the record must fall back to the
* ORIGINAL container completely: both the path AND the recorded size.
*
* Committing the size before the delete succeeded would leave the record
* claiming the small extracted size while still holding the whole video, so
* totalBytes() under-counts and the upload directory grows past its quota.
*
* This lives in its own file because it partially mocks node:fs, which would
* otherwise leak into every other test in local.test.ts.
*/
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: actual,
rmSync: (path: string, opts?: object) => {
// Simulate the lock on the source video only; every other delete
// (the discarded .mka, temp dirs, the reject path) behaves normally.
if (typeof path === "string" && path.endsWith(".mp4")) {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return actual.rmSync(path, opts as never);
},
};
});
const { LocalMusicProvider } = await import("./local.js");
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
afterEach(() => {
// Recursive teardown without rmSync (mocked above for *.mp4).
for (const f of readdirSync(dir)) {
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
}
try { rmdirSync(dir); } catch { /* best effort */ }
});
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
const src = join(dir, "fixture.mp4");
const r = spawnSync(ffmpeg!, [
"-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
"-f", "lavfi", "-i", "sine=f=440:d=3",
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
], { stdio: "ignore" });
expect(r.status).toBe(0);
const bytes = readFileSync(src);
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
});
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// Fell back to the original container — the extract was discarded.
expect(resolved!.url.endsWith(".mp4")).toBe(true);
expect(existsSync(resolved!.url)).toBe(true);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
const onDisk = statSync(resolved!.url).size;
expect(onDisk).toBe(bytes.length);
// The RECORDED size drives the quota (totalBytes()), so it must describe
// the file actually retained. It is not exposed through search()/toSong,
// but it is persisted to index.json — read it back from there.
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
id: string; size: number; filePath: string;
}>).find((r) => r.id === song.id);
expect(record).toBeDefined();
expect(record!.filePath.endsWith(".mp4")).toBe(true);
// Before the fix this was the (much smaller) .mka size while the whole
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
expect(record!.size).toBe(bytes.length);
// Sanity: the extract really is much smaller, so a wrong commit order
// would have been clearly observable rather than a rounding error.
expect(onDisk).toBeGreaterThan(50_000);
}, 60000);
});
+257 -2
View File
@@ -1,8 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { buildFfmpegArgs } from "../audio/player.js";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
import { LocalMusicProvider, parseMediaProbe } from "./local.js";
let dir: string;
@@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => {
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
// #149: video containers are accepted; only their audio track is kept.
it("still rejects a non-media extension after video was allowed", async () => {
const p = new LocalMusicProvider(dir);
for (const name of ["evil.exe", "evil.html", "evil.mp4.txt", "notes.pdf"]) {
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: name, mimeType: "video/mp4" }),
).rejects.toThrow();
}
});
it("accepts every supported video extension at the extension gate", async () => {
const p = new LocalMusicProvider(dir);
// Junk content: ffmpeg cannot open it, so it is "unrecognised" rather than
// "no audio track" and must be accepted exactly like a truncated .mp3
// always has been. The extension allowlist is what is under test here.
// .m4v is excluded on purpose — see the next test.
for (const ext of [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".ts", ".m2ts", ".ogv"]) {
const song = await p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: `clip${ext}`,
mimeType: "video/mp4",
});
expect(song.platform).toBe("local");
expect(song.name).toBe("clip");
}
});
it("refuses a .m4v raw video elementary stream, which by definition has no audio", async () => {
// .m4v is not a container — ffmpeg's rawvideo demuxer opens arbitrary
// bytes as an MPEG-4 video elementary stream, so it IS recognised and
// genuinely carries no audio track. Refusing it is the correct outcome,
// and it is the one case that distinguishes `recognized` from `probed`.
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: "clip.m4v",
mimeType: "video/x-m4v",
}),
).rejects.toThrow(/音轨/);
});
it("the error message names both audio and video formats", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: "a.exe" }),
).rejects.toThrow(/视频/);
});
});
describe("parseMediaProbe (#149)", () => {
const mp4Banner = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':
Duration: 00:03:27.15, start: 0.000000, bitrate: 1105 kb/s
Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1280x720, 30 fps
Stream #0:1[0x2](und): Audio: aac (LC), 48000 Hz, stereo, fltp, 192 kb/s`;
it("reads duration and detects the audio stream in a video container", () => {
const r = parseMediaProbe(mp4Banner);
expect(r.durationSeconds).toBe(3 * 60 + 27);
expect(r.hasAudio).toBe(true);
});
it("reports hasAudio false for a video with only a video stream", () => {
const silent = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'silent.mp4':
Duration: 00:00:02.00, start: 0.000000, bitrate: 29 kb/s
Stream #0:0[0x1](und): Video: h264 (High 4:4:4 Predictive), yuv444p, 160x120, 10 fps`;
const r = parseMediaProbe(silent);
expect(r.durationSeconds).toBe(2);
expect(r.hasAudio).toBe(false);
});
it("detects a plain audio file", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'a.mp3':
Duration: 00:00:30.02, start: 0.000000, bitrate: 128 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s`);
expect(r.durationSeconds).toBe(30);
expect(r.hasAudio).toBe(true);
});
it("does not mistake an attached cover image for an audio stream", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'cover.mp3':
Duration: 00:00:10.00, start: 0.000000, bitrate: 130 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 100x100 [attached pic]`);
expect(r.hasAudio).toBe(true);
});
it("returns zeros on unparseable output rather than throwing", () => {
const r = parseMediaProbe("ffmpeg: command exploded");
expect(r.durationSeconds).toBe(0);
expect(r.hasAudio).toBe(false);
expect(r.recognized).toBe(false);
});
// The distinction that decides whether an upload is refused: ffmpeg opened
// the file and found no audio (refuse) vs ffmpeg could not open it at all
// (accept, as it always has for truncated audio).
it("marks a readable container recognized and unreadable bytes not", () => {
expect(parseMediaProbe(mp4Banner).recognized).toBe(true);
expect(parseMediaProbe(`[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found
[in#0 @ 0x2] Error opening input: Invalid data found when processing input
Error opening input file junk.mp4.`).recognized).toBe(false);
});
it("rounds fractional durations", () => {
expect(parseMediaProbe("Duration: 00:00:03.60,").durationSeconds).toBe(4);
expect(parseMediaProbe("Duration: 01:02:03.10,").durationSeconds).toBe(3723);
});
});
describe("LocalMusicProvider quota", () => {
@@ -233,3 +345,146 @@ describe("LocalMusicProvider filename handling", () => {
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
// #149 end-to-end: build real containers with the bundled ffmpeg and push
// them through the actual upload path. Skipped automatically if the binary is
// unavailable, so the suite still runs on a machine without it.
describe("LocalMusicProvider video upload, end to end (#149)", () => {
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
/** Render a real container into the temp dir and return its bytes. */
function render(name: string, args: string[]): Buffer {
const out = join(dir, name);
const r = spawnSync(ffmpeg!, ["-y", "-hide_banner", "-loglevel", "error", ...args, out], {
stdio: "ignore",
});
if (r.status !== 0) throw new Error(`fixture render failed: ${name}`);
const buf = readFileSync(out);
rmSync(out, { force: true }); // upload writes its own copy
return buf;
}
const withAudio = (dur: number, vcodec: string, acodec: string) => [
"-f", "lavfi", "-i", `testsrc=s=160x120:r=10:d=${dur}`,
"-f", "lavfi", "-i", `sine=f=440:d=${dur}`,
"-c:v", vcodec, "-c:a", acodec, "-shortest",
];
it.runIf(have)("accepts an mp4, reads its duration, and keeps only the audio", async () => {
const p = new LocalMusicProvider(dir);
const mp4 = render("src.mp4", withAudio(3, "libx264", "aac"));
const song = await p.uploadAudio({
buffer: mp4, originalName: "My Clip.mp4", mimeType: "video/mp4",
});
expect(song.name).toBe("My Clip");
expect(song.platform).toBe("local");
expect(song.duration).toBe(3);
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// The video container is gone; what remains is the extracted audio track.
// AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming
// edit list survives — see extractedAudioExt.
expect(resolved!.url.endsWith(".m4a")).toBe(true);
expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false);
expect(existsSync(resolved!.url)).toBe(true);
expect(statSync(resolved!.url).size).toBeGreaterThan(0);
expect(statSync(resolved!.url).size).toBeLessThan(mp4.length);
}, 60000);
it.runIf(have)("extracted audio is still decodable by the player's ffmpeg args", async () => {
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: render("src2.mp4", withAudio(2, "libx264", "aac")),
originalName: "clip.mp4",
mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const decoded = spawnSync(
ffmpeg!,
[...buildFfmpegArgs(url, 0).slice(0, -1), "-"],
{ maxBuffer: 64 * 1024 * 1024 },
);
expect(decoded.status).toBe(0);
// 2s of 48 kHz stereo s16le ≈ 384000 bytes; allow codec priming slack.
expect(decoded.stdout.length).toBeGreaterThan(300000);
}, 60000);
it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => {
// The strongest statement of "lossless": decode the audio track straight
// out of the source mp4, decode the stored extract, compare the PCM.
// A Matroska remux would NOT pass this — it loses the MP4 edit list that
// discards AAC encoder priming, so it decodes ~23 ms longer.
const p = new LocalMusicProvider(dir);
const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac"));
const sourceCopy = join(dir, "source-kept.mp4");
writeFileSync(sourceCopy, bytes);
const song = await p.uploadAudio({
buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const toPcm = (input: string, pre: string[] = []) => spawnSync(
ffmpeg!,
["-hide_banner", "-loglevel", "error", "-i", input, ...pre,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"],
{ maxBuffer: 128 * 1024 * 1024 },
);
const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]);
const fromExtract = toPcm(url);
expect(fromVideo.status).toBe(0);
expect(fromExtract.status).toBe(0);
expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length);
expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true);
}, 90000);
it.runIf(have)("refuses a video that genuinely has no audio track", async () => {
const p = new LocalMusicProvider(dir);
const silent = render("silent.mp4", [
"-f", "lavfi", "-i", "testsrc=s=160x120:r=10:d=2", "-an",
]);
await expect(
p.uploadAudio({ buffer: silent, originalName: "silent.mp4", mimeType: "video/mp4" }),
).rejects.toThrow(/音轨/);
// The rejected upload must not leave its bytes behind.
expect(readdirSync(dir).filter((f) => f.endsWith(".mp4"))).toEqual([]);
}, 60000);
it.runIf(have)("extracts losslessly from avi/mkv/flv too, not just mp4", async () => {
const p = new LocalMusicProvider(dir);
const cases: Array<[string, string[]]> = [
["a.avi", withAudio(2, "mpeg4", "libmp3lame")],
["a.mkv", withAudio(2, "libx264", "libopus")],
["a.flv", withAudio(2, "flv", "libmp3lame")],
];
for (const [name, args] of cases) {
const song = await p.uploadAudio({
buffer: render(`src-${name}`, args), originalName: name, mimeType: "video/x-msvideo",
});
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mka")).toBe(true);
expect(statSync(url).size).toBeGreaterThan(0);
}
}, 120000);
it.runIf(have)("a plain audio upload is untouched — no extraction, original extension kept", async () => {
const p = new LocalMusicProvider(dir);
const mp3 = render("src.mp3", ["-f", "lavfi", "-i", "sine=f=440:d=2", "-c:a", "libmp3lame"]);
const song = await p.uploadAudio({ buffer: mp3, originalName: "tune.mp3", mimeType: "audio/mpeg" });
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mp3")).toBe(true);
expect(statSync(url).size).toBe(mp3.length); // byte-identical, not remuxed
}, 60000);
});
+223 -24
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
@@ -34,6 +34,56 @@ const AUDIO_EXTENSIONS = new Set([
".ape",
]);
/** Video containers accepted for upload (#149). Only the audio track is ever
* used — the bot has no video output. Playback would work straight from the
* container (ffmpeg selects the audio stream), but we extract the audio on
* upload so a 200 MB clip does not sit on disk for a 3 MB song; see
* extractAudioTrack. `.webm` is deliberately absent: it is already in
* AUDIO_EXTENSIONS and both audio-only and video .webm are handled there. */
const VIDEO_EXTENSIONS = new Set([
".mp4",
".mov",
".avi",
".mkv",
".flv",
".wmv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ts",
".m2ts",
".ogv",
]);
/** Fallback container for an extracted audio track. Matroska takes
* essentially any audio codec, so `-c:a copy` works without knowing what the
* source used — no re-encode, no codec/extension table. */
const EXTRACTED_AUDIO_EXT = ".mka";
/**
* Container to remux an extracted track into, chosen by its codec.
*
* AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder
* priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list,
* and that edit list does NOT survive into Matroska — so an aac→.mka remux
* decodes ~23 ms longer than the source, with the priming samples audible at
* the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the
* track is then fractionally out of step with its own reported duration for
* no reason. Copying aac into .m4a keeps the edit list and decodes
* byte-for-byte identical to the audio inside the original video.
*
* AAC is worth special-casing because it is what mp4 / mov / m4v — the
* formats people actually upload — almost always carry.
*/
function extractedAudioExt(codec: string | null): string {
return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT;
}
function isSupportedUploadExt(ext: string): boolean {
return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext);
}
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
@@ -71,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",
};
+176 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
import {
parseLyrics,
mapNeteaseAlbums,
mapNeteaseSongs,
mapNeteaseArtists,
parseNeteaseTrial,
NeteaseProvider,
} from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -138,4 +145,172 @@ describe("NeteaseProvider.search pagination", () => {
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
it("requests artists (type=100) and returns them alongside songs/albums/playlists", async () => {
const p = new NeteaseProvider("http://x");
const get = vi.fn(async (_path: string, cfg: any) => ({
data:
cfg.params.type === 100
? { result: { artists: [{ id: 6452, name: "Adele", picUrl: "http://p/1.jpg", musicSize: 120 }] } }
: { result: { songs: [], playlists: [], albums: [] } },
}));
(p as any).api = { get };
const res = await p.search("adele", 20, 0);
expect(callByType(get, 100).limit).toBe(20);
expect(callByType(get, 100).offset).toBe(0);
expect(res.artists).toEqual([
{
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
aliases: [],
songCount: 120,
albumCount: undefined,
platform: "netease",
},
]);
});
});
describe("mapNeteaseArtists (artist search + detail)", () => {
it("maps cloudsearch type=100 artist entries", () => {
const out = mapNeteaseArtists([
{
id: 6452,
name: "Adele",
picUrl: "http://p/1.jpg",
alias: ["阿黛尔"],
musicSize: 120,
albumSize: 9,
},
]);
expect(out).toEqual([
{
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
aliases: ["阿黛尔"],
songCount: 120,
albumCount: 9,
platform: "netease",
},
]);
});
it("falls back to img1v1Url/alia and drops non-string or empty aliases", () => {
const out = mapNeteaseArtists([
{ id: 1, name: "X", img1v1Url: "http://p/2.jpg", alia: ["a", "", null, 3] },
]);
expect(out[0].avatarUrl).toBe("http://p/2.jpg");
expect(out[0].aliases).toEqual(["a"]);
expect(out[0].songCount).toBeUndefined();
expect(out[0].albumCount).toBeUndefined();
});
it("returns [] for empty/null input", () => {
expect(mapNeteaseArtists([])).toEqual([]);
expect(mapNeteaseArtists(null as any)).toEqual([]);
expect(mapNeteaseArtists(undefined as any)).toEqual([]);
});
});
describe("NeteaseProvider per-user login (#164)", () => {
function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) {
const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) }));
(p as any).api = { get, defaults: { baseURL: "http://127.0.0.1:3001" } };
return get;
}
it("pollQrLogin returns the cookie without touching the shared account", async () => {
const p = new NeteaseProvider("http://127.0.0.1:3001");
p.setCookie("MUSIC_U=shared");
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=personal" }));
expect(await p.pollQrLogin("k")).toEqual({ status: "confirmed", cookie: "MUSIC_U=personal" });
expect(p.getCookie()).toBe("MUSIC_U=shared");
});
it("pollQrLogin maps the waiting / scanned / expired codes", async () => {
const p = new NeteaseProvider("http://127.0.0.1:3001");
let code = 801;
withGet(p, () => ({ code }));
expect(await p.pollQrLogin("k")).toEqual({ status: "waiting" });
code = 802;
expect(await p.pollQrLogin("k")).toEqual({ status: "scanned" });
code = 800;
expect(await p.pollQrLogin("k")).toEqual({ status: "expired" });
});
it("checkQrCodeStatus still stores the cookie on the shared provider (admin login)", async () => {
const p = new NeteaseProvider("http://127.0.0.1:3001");
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=admin" }));
expect(await p.checkQrCodeStatus("k")).toBe("confirmed");
expect(p.getCookie()).toBe("MUSIC_U=admin");
});
it("withCookie gives a view that fetches FM with the other account's cookie", async () => {
const p = new NeteaseProvider("http://127.0.0.1:3001");
p.setCookie("MUSIC_U=shared");
const personal = p.withCookie("MUSIC_U=personal");
const get = withGet(personal, () => ({ data: [] }));
await personal.getPersonalFm();
expect(get.mock.calls[0][1].params.cookie).toBe("MUSIC_U=personal");
expect(p.getCookie()).toBe("MUSIC_U=shared");
expect(personal.platform).toBe("netease");
});
});
describe("NeteaseProvider.getArtistAllSongs (全部歌曲 paging)", () => {
const rawSongs = [
{ id: 1, name: "A", artists: [{ name: "X" }], album: { name: "Al" }, duration: 200000, fee: 0 },
{ id: 2, name: "B", artists: [{ name: "X" }], album: { name: "Al" }, duration: 100000, fee: 0 },
];
function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) {
const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) }));
(p as any).api = { get };
return get;
}
it("pages /artist/songs with order=hot and reports total/hasMore", async () => {
const p = new NeteaseProvider("http://x");
const get = withGet(p, () => ({ songs: rawSongs, total: 345, more: true }));
const page = await p.getArtistAllSongs("46487", 50, 50);
expect(get).toHaveBeenCalledTimes(1);
expect(get.mock.calls[0][0]).toBe("/artist/songs");
expect(get.mock.calls[0][1].params).toMatchObject({
id: "46487",
limit: 50,
offset: 50,
order: "hot",
});
expect(page.songs.map((s) => s.id)).toEqual(["1", "2"]);
expect(page.total).toBe(345);
expect(page.hasMore).toBe(true);
});
it("derives hasMore from total when the upstream omits `more`", async () => {
const p = new NeteaseProvider("http://x");
withGet(p, (_path, cfg) => ({
songs: rawSongs.slice(cfg.params.offset, cfg.params.offset + cfg.params.limit),
total: 2,
}));
expect((await p.getArtistAllSongs("1", 0, 1)).hasMore).toBe(true);
expect((await p.getArtistAllSongs("1", 1, 1)).hasMore).toBe(false);
});
it("clamps limit to 100, offset to >= 0, and derives a total when absent", async () => {
const p = new NeteaseProvider("http://x");
const get = withGet(p, () => ({ songs: rawSongs }));
const page = await p.getArtistAllSongs("1", -5, 500);
expect(get.mock.calls[0][1].params).toMatchObject({ limit: 100, offset: 0 });
expect(page.total).toBe(2);
expect(page.hasMore).toBe(false);
});
});
+121 -11
View File
@@ -10,6 +10,9 @@ import type {
QrCodeResult,
AuthStatus,
Album,
Artist,
ArtistDetail,
ArtistSongPage,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -69,6 +72,21 @@ export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
}));
}
export function mapNeteaseArtists(raw: any[] | null | undefined): Artist[] {
if (!Array.isArray(raw)) return [];
return raw.map((a: any) => ({
id: String(a.id),
name: a.name ?? "",
avatarUrl: a.picUrl ?? a.img1v1Url ?? "",
aliases: (a.alias ?? a.alia ?? []).filter(
(x: unknown): x is string => typeof x === "string" && x.length > 0
),
songCount: a.musicSize ?? undefined,
albumCount: a.albumSize ?? undefined,
platform: "netease",
}));
}
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s: any) => ({
@@ -111,8 +129,10 @@ export class NeteaseProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
@@ -135,7 +155,7 @@ export class NeteaseProvider implements MusicProvider {
// /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([
const [songRes, playlistRes, albumRes, artistRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}),
@@ -151,6 +171,9 @@ export class NeteaseProvider implements MusicProvider {
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 100, limit, offset, ...this.cookieParams },
}),
]);
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
@@ -167,7 +190,9 @@ export class NeteaseProvider implements MusicProvider {
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
const artists = mapNeteaseArtists(artistRes.data?.result?.artists);
return { songs, playlists, albums, artists };
}
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
@@ -215,6 +240,69 @@ export class NeteaseProvider implements MusicProvider {
return mapNeteaseSongs(res.data?.songs);
}
async getArtistDetail(artistId: string): Promise<ArtistDetail | null> {
// /artists returns { artist, hotSongs }; the hot songs are fetched
// separately via /artist/songs (order=hot) so the artist page's three
// upstream calls stay independent of each other.
const res = await this.api.get("/artists", {
params: { id: artistId, ...this.cookieParams },
});
const a = res.data?.artist;
if (!a) return null;
return {
...mapNeteaseArtists([a])[0],
description: a.briefDesc ?? "",
};
}
async getArtistSongs(artistId: string, limit = 50): Promise<Song[]> {
const res = await this.api.get("/artist/songs", {
params: {
id: artistId,
limit,
offset: 0,
order: "hot",
...this.cookieParams,
},
});
return mapNeteaseSongs(res.data?.songs);
}
async getArtistAlbums(artistId: string, limit = 20): Promise<Album[]> {
const res = await this.api.get("/artist/album", {
params: { id: artistId, limit, offset: 0, ...this.cookieParams },
});
return mapNeteaseAlbums(res.data?.hotAlbums);
}
/**
* Full catalogue page for the artist page's "全部歌曲" list: /artist/songs
* supports real offset paging (Adele reports total 345 with more=true, and
* offset=50/100/150 each return a fresh slice of 50). order=hot keeps the page
* ordering identical to getArtistSongs so the hot preview and the full list
* are one continuous ranking.
*/
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
const safeOffset = Math.max(0, Math.trunc(offset) || 0);
const safeLimit = Math.max(1, Math.min(Math.trunc(limit) || 50, 100));
const res = await this.api.get("/artist/songs", {
params: {
id: artistId,
limit: safeLimit,
offset: safeOffset,
order: "hot",
...this.cookieParams,
},
});
const songs = mapNeteaseSongs(res.data?.songs);
const reported = Number(res.data?.total);
const total = Number.isFinite(reported) && reported > 0 ? reported : safeOffset + songs.length;
const more = res.data?.more;
const hasMore =
typeof more === "boolean" ? more : safeOffset + songs.length < total;
return { songs, total, hasMore };
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get("/lyric", {
params: { id: songId, ...this.cookieParams },
@@ -243,25 +331,47 @@ export class NeteaseProvider implements MusicProvider {
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const { status, cookie } = await this.pollQrLogin(key);
if (cookie) this.cookie = cookie;
return status;
}
/**
* Poll a QR login and hand back the resulting cookie WITHOUT storing it on
* this provider — for a web user linking their own account (#164), which
* must never replace the bot's shared login.
*/
async pollQrLogin(
key: string
): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }> {
const res = await this.api.get("/login/qr/check", {
params: { key, timestamp: Date.now() },
});
const code = res.data?.code;
switch (code) {
switch (res.data?.code) {
case 801:
return "waiting";
return { status: "waiting" };
case 802:
return "scanned";
return { status: "scanned" };
case 803:
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
return res.data?.cookie
? { status: "confirmed", cookie: res.data.cookie }
: { status: "confirmed" };
default:
return "expired";
return { status: "expired" };
}
}
/**
* A provider for the same API server logged in as another account (#164):
* a web user's personal FM uses their own taste instead of the shared login.
*/
withCookie(cookie: string): NeteaseProvider {
const view = new NeteaseProvider(this.baseUrl);
view.setQuality(this.quality);
view.setCookie(cookie);
return view;
}
async sendSmsCode(phone: string): Promise<boolean> {
const res = await this.api.get("/captcha/sent", {
params: { phone },
+42
View File
@@ -59,6 +59,34 @@ export interface Album {
platform: Platform;
}
/** An artist / singer entity. Only sources with a real artist concept expose
* these (NetEase, QQ); the others simply never return `SearchResult.artists`
* and leave the optional provider methods unimplemented. */
export interface Artist {
id: string;
name: string;
avatarUrl: string;
platform: Platform;
/** Alternate names / romanizations (NetEase alias, QQ other_name). */
aliases?: string[];
songCount?: number;
albumCount?: number;
}
export interface ArtistDetail extends Artist {
/** Short biography, when the source provides one. */
description?: string;
}
/** One page of an artist's COMPLETE catalogue (the "全部歌曲" list), as opposed
* to `getArtistSongs`, which only ever returns the hot top-N. */
export interface ArtistSongPage {
songs: Song[];
/** Total tracks the source reports for this artist (best effort). */
total: number;
hasMore: boolean;
}
export interface LyricLine {
time: number; // seconds
text: string;
@@ -69,6 +97,8 @@ export interface SearchResult {
songs: Song[];
playlists: Playlist[];
albums: Album[];
/** Present only for sources with an artist entity (NetEase, QQ). */
artists?: Artist[];
}
export interface QrCodeResult {
@@ -108,4 +138,16 @@ export interface MusicProvider {
getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
getArtistDetail?(artistId: string): Promise<ArtistDetail | null>;
/** The artist's most popular tracks, best-first. */
getArtistSongs?(artistId: string, limit?: number): Promise<Song[]>;
/** One page of the artist's full catalogue, best-first. Sources that can only
* expose a fixed top-N list leave this unimplemented (the route then 501s and
* the web hides the "全部歌曲" section). */
getArtistAllSongs?(
artistId: string,
offset?: number,
limit?: number
): Promise<ArtistSongPage>;
getArtistAlbums?(artistId: string, limit?: number): Promise<Album[]>;
}
+466 -1
View File
@@ -8,7 +8,7 @@ vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
import { mapQqAlbums, mapQqArtists, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -171,4 +171,469 @@ describe("QQMusicProvider.search pagination", () => {
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
it("adds the singer sub-request (search_type 1) to the same musicu batch", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("周杰伦", 20, 0);
const d = musicuReqData();
expect(d.req_artist.param.search_type).toBe(1);
expect(d.req_artist.param.num_per_page).toBe(20);
expect(d.req_artist.param.page_num).toBe(1);
});
it("returns singers even when the song list is empty (no client_search_cp fallback)", async () => {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
req_artist: {
data: { body: { singer: { list: [{ singerMID: "m1", singerName: "Adele", songNum: 88 }] } } },
},
},
};
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
const res = await p.search("Adele", 20, 0);
expect(res.songs).toEqual([]);
expect(res.artists).toEqual([
{
id: "m1",
name: "Adele",
avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000m1.jpg",
songCount: 88,
albumCount: undefined,
platform: "qq",
},
]);
expect(mockGet.mock.calls.some((c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp")).toBe(false);
});
});
describe("mapQqArtists (singer search + detail)", () => {
it("maps singer list entries and builds the 500px portrait from the MID", () => {
const out = mapQqArtists([
{
singerMID: "abc",
singerName: "周杰伦",
singerPic: "http://y.gtimg.cn/music/photo_new/T001R150x150M000abc_11.jpg",
songNum: 500,
albumNum: 30,
},
]);
expect(out).toEqual([
{
id: "abc",
name: "周杰伦",
avatarUrl: "https://y.gtimg.cn/music/photo_new/T001R500x500M000abc.jpg",
songCount: 500,
albumCount: 30,
platform: "qq",
},
]);
});
it("falls back to the given picture when no MID is present", () => {
const out = mapQqArtists([
{ singerID: 42, singerName: "Y", singerPic: "https://y.gtimg.cn/music/photo_new/x.jpg" },
]);
expect(out).toEqual([
{
id: "42",
name: "Y",
avatarUrl: "https://y.gtimg.cn/music/photo_new/x.jpg",
songCount: undefined,
albumCount: undefined,
platform: "qq",
},
]);
});
it("drops entries without an id or name and tolerates empty input", () => {
expect(mapQqArtists([{ singerName: "no id" }, { singerMID: "x" }])).toEqual([]);
expect(mapQqArtists([])).toEqual([]);
expect(mapQqArtists(null as any)).toEqual([]);
expect(mapQqArtists(undefined as any)).toEqual([]);
});
});
describe("QQMusicProvider.getArtistAllSongs (album aggregation)", () => {
beforeEach(() => {
mockGet.mockReset();
});
function songRaw(mid: string, title: string) {
return { mid, title, singer: [{ name: "Adele" }], album: { mid: "al1", name: "Album" }, interval: 200 };
}
it("does not cache a hot-only catalogue when the singer lookup for the album scan fails", async () => {
let singerCalls = 0;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/getAlbumInfo") {
return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
}
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
if (++singerCalls === 2) throw new Error("temporary singer lookup failure");
return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
}
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
return { data: { req_album: { data: { body: { album: { list } } } } } };
});
const provider = new QQMusicProvider("http://x");
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot"]);
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
});
it.each([
{ code: 0, req_album: { code: 2000 } },
{ code: 500, req_album: { data: { body: { album: { list: [] } } } } },
{ req_album: { data: { body: {} } } },
{ req_album: { data: { body: { album: { list: {} } } } } },
])("does not cache logical or malformed album-search failure %#", async (failedResponse) => {
let failed = true;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/getAlbumInfo") return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
const data = JSON.parse(cfg.params.data);
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
if (failed) return { data: failedResponse };
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
return { data: { code: 0, req_album: { code: 0, data: { body: { album: { list } } } } } };
});
const provider = new QQMusicProvider("http://x");
const degraded = await provider.getArtistAllSongs("m1");
expect(degraded.songs.map((s) => s.id)).toEqual(["hot"]);
failed = false;
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
});
it("includes more than 50 short albums when the catalogue is below the 500-song ceiling", async () => {
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: (page) => Array.from({ length: page === 1 ? 50 : page === 2 ? 10 : 0 }, (_, i) => ({ albumMID: `al${(page - 1) * 50 + i}`, singerMID: "m1" })),
albumSongs: Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`al${i}`, [songRaw(`s${i}`, `${i}`)]])),
});
const result = await new QQMusicProvider("http://x").getArtistAllSongs("m1", 0, 100);
expect(result.songs).toHaveLength(61);
expect(result.total).toBe(61);
expect(result.hasMore).toBe(false);
});
it.each([
{ response: { code: 2000, data: { list: [] } } },
{ response: { data: {} } },
{ response: { data: { list: [{}] } } },
{ response: { data: { list: [{ mid: "" }] } } },
{ response: { data: { list: [{ mid: " " }] } } },
{ response: { data: { list: [{ mid: {} }] } } },
])("does not cache a catalogue after a logical or malformed album-song failure %#", async (failedResponse) => {
let failed = true;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/getAlbumInfo") return { data: failed ? failedResponse : { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
const data = JSON.parse(cfg.params.data);
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("hot", "Hot")] } } } };
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
return { data: { req_album: { data: { body: { album: { list } } } } } };
});
const provider = new QQMusicProvider("http://x");
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot"]);
failed = false;
expect((await provider.getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
});
it("does not cache a catalogue whose hot-song rows contain no song identifier", async () => {
let failed = true;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/getAlbumInfo") return { data: { response: { data: { list: [songRaw("album-track", "Album track")] } } } };
if (url !== "/cgi-bin/musicu.fcg") return { data: {} };
const data = JSON.parse(cfg.params.data);
if (data.req_0) return { data: { req_0: { data: { singer_info: { mid: "m1", name: "Adele" }, songlist: failed ? [{}] : [songRaw("hot", "Hot")] } } } };
const list = data.req_album.param.page_num === 1 ? [{ albumMID: "al1", singerMID: "m1" }] : [];
return { data: { req_album: { data: { body: { album: { list } } } } } };
});
const provider = new QQMusicProvider("http://x");
await provider.getArtistAllSongs("m1");
failed = false;
const recovered = await provider.getArtistAllSongs("m1");
expect(recovered.songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
expect(recovered.hasMore).toBe(false);
});
it("bounds a large catalogue at 500 unique songs while reporting remaining tracks", async () => {
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: () => [{ albumMID: "al1", singerMID: "m1" }],
albumSongs: { al1: Array.from({ length: 600 }, (_, i) => songRaw(`s${i}`, `${i}`)) },
});
const provider = new QQMusicProvider("http://x");
const last = await provider.getArtistAllSongs("m1", 400, 100);
expect(last.songs).toHaveLength(100);
expect(last.hasMore).toBe(true);
expect(last.total).toBeGreaterThan(500);
expect((await provider.getArtistAllSongs("m1", 500, 100)).songs).toEqual([]);
});
it("reports a complete catalogue of exactly 500 songs without an extra page", async () => {
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: () => [{ albumMID: "al1", singerMID: "m1" }],
albumSongs: { al1: Array.from({ length: 499 }, (_, i) => songRaw(`s${i}`, `${i}`)) },
});
const last = await new QQMusicProvider("http://x").getArtistAllSongs("m1", 400, 100);
expect(last.total).toBe(500);
expect(last.hasMore).toBe(false);
});
it("does not treat a page without matching singers as the end of the search", async () => {
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: (page) => page === 1
? Array.from({ length: 50 }, (_, i) => ({ albumMID: `other${i}`, singerMID: "other" }))
: page === 2 ? [{ albumMID: "al1", singerMID: "m1" }] : [],
albumSongs: { al1: [songRaw("album-track", "Album track")] },
});
expect((await new QQMusicProvider("http://x").getArtistAllSongs("m1")).songs.map((s) => s.id)).toEqual(["hot", "album-track"]);
});
it("leaves repeated search pages incomplete and retryable", async () => {
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: () => Array.from({ length: 50 }, (_, i) => ({ albumMID: `al${i}`, singerMID: "m1" })),
});
const provider = new QQMusicProvider("http://x");
expect((await provider.getArtistAllSongs("m1")).hasMore).toBe(true);
mockCatalogue({ hot: [songRaw("hot", "Hot")], albumSearch: () => [] });
const recovered = await provider.getArtistAllSongs("m1");
expect(recovered.total).toBe(1);
expect(recovered.hasMore).toBe(false);
});
it("bounds endless search pages of empty albums and leaves the partial result uncached", async () => {
let searchCalls = 0;
mockCatalogue({
hot: [songRaw("hot", "Hot")],
albumSearch: (page) => {
if (++searchCalls > 110) throw new Error("unbounded upstream scan");
return Array.from({ length: 50 }, (_, i) => ({ albumMID: `al${page}-${i}`, singerMID: "m1" }));
},
});
const provider = new QQMusicProvider("http://x");
const partial = await provider.getArtistAllSongs("m1");
expect(searchCalls).toBeLessThanOrEqual(100);
expect(partial.hasMore).toBe(true);
mockCatalogue({ hot: [songRaw("hot", "Hot")], albumSearch: () => [] });
expect((await provider.getArtistAllSongs("m1")).hasMore).toBe(false);
});
/** singer detail (top 50) + album search pages + per-album song lists. */
function mockCatalogue(opts: {
hot?: any[];
albumSearch?: (page: number) => any[];
albumSongs?: Record<string, any[]>;
albumInfoFails?: boolean;
}) {
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: {
singer_info: { mid: "m1", name: "Adele" },
total_song: 250,
songlist: opts.hot ?? [],
},
},
},
};
}
const page = data.req_album?.param?.page_num ?? 1;
return {
data: {
req_album: { data: { body: { album: { list: (opts.albumSearch ?? (() => []))(page) } } } },
},
};
}
if (url === "/getAlbumInfo") {
if (opts.albumInfoFails) throw new Error("album down");
return { data: { response: { data: { list: opts.albumSongs?.[cfg.params.albummid] ?? [] } } } };
}
return { data: {} };
});
}
it("merges the hot tracks with every album track, de-duplicated and paged", async () => {
mockCatalogue({
hot: [songRaw("s1", "Hot 1"), songRaw("s2", "Hot 2")],
albumSearch: () => [
{ albumMID: "al1", albumName: "A", singerMID: "m1" },
{ albumMID: "al2", albumName: "B", singerMID: "m1" },
{ albumMID: "other", albumName: "C", singerMID: "m9" },
],
albumSongs: {
al1: [songRaw("s1", "Hot 1"), songRaw("s3", "Album 1")],
al2: [songRaw("s4", "Album 2")],
},
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 10);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s2", "s3", "s4"]);
expect(page.total).toBe(4);
expect(page.hasMore).toBe(false);
// The unrelated album (singerMID m9) is never fetched.
const albumCalls = mockGet.mock.calls.filter((c: any[]) => c[0] === "/getAlbumInfo");
expect(albumCalls.map((c: any[]) => c[1].params.albummid).sort()).toEqual(["al1", "al2"]);
// A short page exhausts the search without another upstream request.
const searchPages = mockGet.mock.calls
.filter((c: any[]) => c[0] === "/cgi-bin/musicu.fcg")
.map((c: any[]) => JSON.parse(c[1].params.data).req_album?.param?.page_num)
.filter(Boolean);
expect(searchPages).toEqual([1]);
});
it("slices pages with offset/limit and reports hasMore", async () => {
mockCatalogue({
hot: [songRaw("s1", "1"), songRaw("s2", "2"), songRaw("s3", "3")],
albumSearch: () => [],
});
const p = new QQMusicProvider("http://x");
const first = await p.getArtistAllSongs("m1", 0, 2);
expect(first.songs.map((s) => s.id)).toEqual(["s1", "s2"]);
expect(first.total).toBe(3);
expect(first.hasMore).toBe(true);
const second = await p.getArtistAllSongs("m1", 2, 2);
expect(second.songs.map((s) => s.id)).toEqual(["s3"]);
expect(second.hasMore).toBe(false);
});
it("caches the assembled catalogue (one upstream sweep per singer)", async () => {
mockCatalogue({
hot: [songRaw("s1", "1")],
albumSearch: () => [{ albumMID: "al1", albumName: "A", singerMID: "m1" }],
albumSongs: { al1: [songRaw("s9", "9")] },
});
const p = new QQMusicProvider("http://x");
await p.getArtistAllSongs("m1", 0, 50);
const callsAfterFirst = mockGet.mock.calls.length;
const page = await p.getArtistAllSongs("m1", 0, 50);
expect(mockGet.mock.calls.length).toBe(callsAfterFirst);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
});
it("degrades to the hot list when album lookups fail", async () => {
mockCatalogue({
hot: [songRaw("s1", "1")],
albumSearch: () => [{ albumMID: "al1", albumName: "A", singerMID: "m1" }],
albumInfoFails: true,
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 50);
expect(page.songs.map((s) => s.id)).toEqual(["s1"]);
expect(page.total).toBe(250);
expect(page.hasMore).toBe(true);
});
it("retries a failed album search once before giving up", async () => {
let albumSearchCalls = 0;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: { singer_info: { mid: "m1", name: "Adele" }, songlist: [songRaw("s1", "1")] },
},
},
};
}
albumSearchCalls++;
if (albumSearchCalls === 1) throw new Error("blip");
const list =
data.req_album.param.page_num === 1
? [{ albumMID: "al1", albumName: "A", singerMID: "m1" }]
: [];
return { data: { req_album: { data: { body: { album: { list } } } } } };
}
if (url === "/getAlbumInfo") {
return { data: { response: { data: { list: [songRaw("s9", "9")] } } } };
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
const page = await p.getArtistAllSongs("m1", 0, 50);
const page1Calls = mockGet.mock.calls.filter((c: any[]) => {
if (c[0] !== "/cgi-bin/musicu.fcg") return false;
return JSON.parse(c[1].params.data).req_album?.param?.page_num === 1;
}).length;
expect(page1Calls).toBe(2);
expect(page.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
});
it("does not cache a catalogue degraded by a failed album search", async () => {
let albumSearchFails = true;
mockGet.mockImplementation(async (url: string, cfg: any) => {
if (url === "/cgi-bin/musicu.fcg") {
const data = JSON.parse(cfg.params.data);
if (data.req_0) {
return {
data: {
req_0: {
data: {
singer_info: { mid: "m1", name: "Adele" },
songlist: [songRaw("s1", "1")],
},
},
},
};
}
if (albumSearchFails) throw new Error("upstream hiccup");
return {
data: {
req_album: {
data: { body: { album: { list: [{ albumMID: "al1", albumName: "A", singerMID: "m1" }] } } },
},
},
};
}
if (url === "/getAlbumInfo") {
return { data: { response: { data: { list: [songRaw("s9", "9")] } } } };
}
return { data: {} };
});
const p = new QQMusicProvider("http://x");
// The album search fails twice (call + retry) → hot list only, and the
// degraded result must not be cached.
const degraded = await p.getArtistAllSongs("m1", 0, 50);
expect(degraded.songs.map((s) => s.id)).toEqual(["s1"]);
expect(degraded.total).toBe(1);
albumSearchFails = false;
const full = await p.getArtistAllSongs("m1", 0, 50);
expect(full.songs.map((s) => s.id)).toEqual(["s1", "s9"]);
expect(full.total).toBe(2);
});
});
+290 -3
View File
@@ -10,6 +10,9 @@ import type {
QrCodeResult,
AuthStatus,
Album,
Artist,
ArtistDetail,
ArtistSongPage,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -40,6 +43,45 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
/** True when a search_type=2 album search entry really belongs to this singer.
* QQ fills singerMID for most albums; older entries only carry singer_list. */
function isArtistAlbum(a: any, artistId: string): boolean {
const mid = a?.singerMID ?? a?.singer_mid;
if (mid) return String(mid) === artistId;
const singers = a?.singer_list ?? a?.singer ?? [];
return (
Array.isArray(singers) &&
singers.some((s: any) => String(s?.mid ?? s?.singerMID ?? "") === artistId)
);
}
/** Assembling a QQ singer's full catalogue costs one album-song request per
* album, so the merged list is memoised per singer for a while. */
const ARTIST_CATALOG_TTL_MS = 10 * 60 * 1000;
const ARTIST_CATALOG_MAX_ENTRIES = 20;
/** Bound pathological search responses even when every album is empty or all
* tracks are duplicates. Hitting this guard is an incomplete, uncached scan. */
const ARTIST_ALBUM_MAX_PAGES = 100;
const ARTIST_ALBUM_CONCURRENCY = 5;
const ARTIST_CATALOG_MAX_SONGS = 500;
interface ArtistCatalog {
songs: Song[];
total: number;
incomplete: boolean;
}
/** A malformed row must not disappear in the mapper and make an incomplete
* artist catalogue look like a successful, cacheable empty album. */
function isQqSongRow(raw: unknown): boolean {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const song = raw as Record<string, unknown>;
const id = song.mid ?? song.songmid ?? song.songMID ?? song.id ?? song.songid ?? song.songId;
return typeof id === "string"
? id.trim().length > 0
: typeof id === "number" && Number.isSafeInteger(id) && id > 0;
}
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s) => {
@@ -91,6 +133,33 @@ export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
});
}
/** QQ hands out http:// image URLs; the WebUI is often served over https. */
function httpsImage(url: unknown): string {
return typeof url === "string" ? url.replace(/^http:\/\//i, "https://") : "";
}
export function mapQqArtists(raw: any[] | null | undefined): Artist[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => {
const id = String(a.singerMID ?? a.singer_mid ?? a.mid ?? a.singerID ?? a.singerId ?? "");
const mid = a.singerMID ?? a.singer_mid ?? a.mid;
return {
id,
name: a.singerName ?? a.name ?? "",
// Search returns a 150px portrait; the MID builds the 500px one the
// artist page wants, so prefer it and only fall back to the given URL.
avatarUrl: mid
? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`
: httpsImage(a.singerPic ?? a.pic),
songCount: a.songNum ?? undefined,
albumCount: a.albumNum ?? undefined,
platform: "qq" as const,
};
})
.filter((a) => a.id && a.name);
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -195,6 +264,13 @@ export class QQMusicProvider implements MusicProvider {
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
},
// search_type 1 = singers. Folded into the same batch so artist search
// costs no extra round-trip.
req_artist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 1 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
@@ -202,7 +278,11 @@ export class QQMusicProvider implements MusicProvider {
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const artistList: any[] =
res.data?.req_artist?.data?.body?.singer?.list ?? [];
// Only fall back to the older client_search_cp path when the batch came
// back completely empty — an artist-only hit is a real result.
if (songList.length === 0 && artistList.length === 0) return null;
const songs = mapQqSongs(songList);
@@ -218,7 +298,7 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq" as const,
}));
return { songs, playlists, albums };
return { songs, playlists, albums, artists: mapQqArtists(artistList) };
} catch {
return null;
}
@@ -467,7 +547,214 @@ export class QQMusicProvider implements MusicProvider {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return mapQqSongs(res.data?.response?.data?.list ?? []);
const response = res.data?.response;
const list = response?.data?.list;
if (
(res.data?.code != null && Number(res.data.code) !== 0) ||
(response?.code != null && Number(response.code) !== 0) ||
!Array.isArray(list) || !list.every(isQqSongRow)
) {
throw new Error("QQ album-song lookup failed");
}
return mapQqSongs(list);
}
/** music.web_singer_info_svr / get_singer_detail_info — singer info plus up
* to `num` of their hottest songs (sort 5 = popularity). Returns null on any
* failure so callers can degrade instead of throwing. */
private async fetchSingerDetail(singerMid: string, num: number): Promise<any | null> {
try {
const reqData = JSON.stringify({
req_0: {
module: "music.web_singer_info_svr",
method: "get_singer_detail_info",
param: {
singermid: singerMid,
sort: 5,
num: Math.max(1, Math.min(num, 50)),
begin: 0,
},
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const response = res.data?.req_0;
const data = response?.data;
if (
(res.data?.code != null && Number(res.data.code) !== 0) ||
(response?.code != null && Number(response.code) !== 0) ||
typeof data?.singer_info?.name !== "string" ||
!data.singer_info.name ||
!Array.isArray(data.songlist) || !data.songlist.every(isQqSongRow)
) return null;
return data;
} catch {
return null;
}
}
async getArtistDetail(artistId: string): Promise<ArtistDetail | null> {
const data = await this.fetchSingerDetail(artistId, 1);
if (!data) return null;
const info = data.singer_info ?? {};
const mid = String(info.mid ?? artistId);
if (!mid) return null;
return {
id: mid,
name: info.name ?? "",
avatarUrl: `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg`,
aliases: info.other_name ? [String(info.other_name)] : [],
songCount: data.total_song ?? undefined,
albumCount: data.total_album ?? undefined,
platform: "qq" as const,
description: data.singer_brief ?? "",
};
}
async getArtistSongs(artistId: string, limit = 50): Promise<Song[]> {
const data = await this.fetchSingerDetail(artistId, limit);
return mapQqSongs(data?.songlist ?? []);
}
/**
* One page of the singer's full catalogue. get_singer_detail_info ignores its
* `begin` parameter (begin=0/50/100 all return the same top 50 — verified
* 2026-10) and QQ has no working singer-song-list endpoint, so the catalogue
* is assembled from every album the singer owns: the hot 50 first (they rank
* best) followed by the album tracks, de-duplicated by songmid.
*/
async getArtistAllSongs(artistId: string, offset = 0, limit = 50): Promise<ArtistSongPage> {
const catalogue = await this.buildArtistCatalog(artistId);
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(Math.trunc(limit) || 50, 100)) : 50;
const songs = catalogue.songs.slice(safeOffset, safeOffset + safeLimit);
return {
songs,
total: catalogue.total,
hasMore: safeOffset + songs.length < catalogue.total || catalogue.incomplete,
};
}
/** Memoised full catalogues, keyed by singer MID (see ARTIST_CATALOG_TTL_MS). */
private artistCatalog = new Map<string, { at: number; catalogue: ArtistCatalog }>();
private async buildArtistCatalog(artistId: string): Promise<ArtistCatalog> {
const cached = this.artistCatalog.get(artistId);
if (cached && Date.now() - cached.at < ARTIST_CATALOG_TTL_MS) return cached.catalogue;
const merged: Song[] = [];
const seen = new Set<string>();
const push = (song: Song) => {
if (!song.id || seen.has(song.id)) return;
seen.add(song.id);
if (merged.length < ARTIST_CATALOG_MAX_SONGS) merged.push(song);
};
const hot = await this.fetchSingerDetail(artistId, 50);
for (const song of mapQqSongs(hot?.songlist)) push(song);
const detail = await this.fetchSingerDetail(artistId, 1);
const name = detail?.singer_info?.name;
let failed = !hot || !detail;
let complete = false;
const albumIds = new Set<string>();
const searchAlbumIds = new Set<string>();
if (name) {
for (let page = 1; page <= ARTIST_ALBUM_MAX_PAGES && merged.length < ARTIST_CATALOG_MAX_SONGS; page++) {
const list = (await this.searchArtistAlbums(name, page, 50)) ?? (await this.searchArtistAlbums(name, page, 50));
if (list === null) { failed = true; break; }
if (list.length === 0) { complete = true; break; }
const batchIds: string[] = [];
let freshSearchEntries = 0;
for (const entry of list) {
const mid = String(entry?.albumMID ?? entry?.album_mid ?? "");
if (!mid) { failed = true; continue; }
if (!searchAlbumIds.has(mid)) { searchAlbumIds.add(mid); freshSearchEntries++; }
if (isArtistAlbum(entry, artistId) && !albumIds.has(mid)) {
albumIds.add(mid);
batchIds.push(mid);
}
}
// Repeated pages cannot prove exhaustion, but must not loop forever.
if (freshSearchEntries === 0) { failed = true; break; }
let fetchedAlbums = 0;
for (let i = 0; i < batchIds.length && merged.length < ARTIST_CATALOG_MAX_SONGS; i += ARTIST_ALBUM_CONCURRENCY) {
const batch = batchIds.slice(i, i + ARTIST_ALBUM_CONCURRENCY);
const lists = await Promise.all(batch.map((mid) =>
this.getAlbumSongs(mid).catch(() => { failed = true; return [] as Song[]; })
));
fetchedAlbums += batch.length;
for (const songs of lists) for (const song of songs) push(song);
}
if (list.length < 50 && fetchedAlbums === batchIds.length && seen.size <= ARTIST_CATALOG_MAX_SONGS) { complete = true; break; }
}
}
const incomplete = failed || !complete;
const reported = Math.max(0, ...[hot?.total_song, detail?.total_song].map((n) => Number.isFinite(Number(n)) ? Math.trunc(Number(n)) : 0));
const catalogue: ArtistCatalog = {
songs: merged,
total: incomplete ? Math.max(seen.size, reported, merged.length === ARTIST_CATALOG_MAX_SONGS ? ARTIST_CATALOG_MAX_SONGS + 1 : 0) : merged.length,
incomplete,
};
// Cache complete catalogues and intentional 500-song truncation only.
// Failure, repeated pages and an exhausted scan budget must remain retryable.
if (!failed && (complete || merged.length === ARTIST_CATALOG_MAX_SONGS)) {
if (this.artistCatalog.size >= ARTIST_CATALOG_MAX_ENTRIES) {
const oldest = this.artistCatalog.keys().next().value;
if (oldest !== undefined) this.artistCatalog.delete(oldest);
}
this.artistCatalog.set(artistId, { at: Date.now(), catalogue });
}
return catalogue;
}
/** search_type=2 album search for a singer name — raw entries, null on failure. */
private async searchArtistAlbums(
name: string,
pageNum: number,
numPerPage: number
): Promise<any[] | null> {
try {
const reqData = JSON.stringify({
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
query: name,
num_per_page: Math.max(10, Math.min(numPerPage, 50)),
page_num: pageNum,
search_type: 2,
},
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const response = res.data?.req_album;
const list = response?.data?.body?.album?.list;
if (
(res.data?.code != null && Number(res.data.code) !== 0) ||
(response?.code != null && Number(response.code) !== 0) ||
!Array.isArray(list)
) return null;
return list;
} catch {
return null;
}
}
async getArtistAlbums(artistId: string, limit = 20): Promise<Album[]> {
// QQ has no working "albums for this singer MID" endpoint: the homepage tab
// API returns a null AlbumList and music.web_singer_info_svr/get_singer_album
// returns an empty list even with a logged-in cookie (verified 2026-10).
// The album shelf is therefore built from the album search for the singer's
// name, filtered down to entries whose singerMID actually matches.
const detail = await this.fetchSingerDetail(artistId, 1);
const name = detail?.singer_info?.name;
if (!name) return [];
const list = (await this.searchArtistAlbums(name, 1, limit)) ?? [];
const mine = list.filter((a: any) => isArtistAlbum(a, artistId));
return mapQqAlbums(mine).slice(0, limit);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
+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) {
+51 -1
View File
@@ -167,7 +167,12 @@ export class TS6HttpQuery {
/** List clients on a virtual server */
async clientList(sid = 1): Promise<HttpQueryResult> {
return this.request("GET", `/1/clientlist?sid=${sid}`);
const path = `/1/clientlist?sid=${sid}`;
const result = await this.request("GET", path);
if (result.status < 200 || result.status >= 300) {
throw new HttpQueryError(path, result.status, result.body);
}
return result;
}
/** List channels on a virtual server */
@@ -209,6 +214,51 @@ export class TS6HttpQuery {
return result;
}
/**
* Edit a specific connected client.
*
* clientUpdate() modifies the HTTP Query client itself.
* clientEdit() explicitly targets the supplied clid.
*/
async clientEdit(
clid: number,
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
const path = `/1/clientedit?sid=${sid}`;
const result = await this.request("POST", path, {
clid,
...properties,
});
if (result.status < 200 || result.status >= 300) {
throw new HttpQueryError(path, result.status, result.body);
}
return result;
}
/**
* Edit a specific channel.
*/
async channelEdit(
cid: number,
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
const path = `/1/channeledit?sid=${sid}`;
const result = await this.request("POST", path, {
cid,
...properties,
});
if (result.status < 200 || result.status >= 300) {
throw new HttpQueryError(path, result.status, result.body);
}
return result;
}
/** Move a client to a channel */
async clientMove(
clid: number,
+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;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore, type AuditStore } from "../../data/audit.js";
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createApiKeysRouter } from "./api-keys.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("api-keys router", () => {
let botDb: BotDatabase;
let app: express.Express;
let sessions: SessionStore;
let apiKeys: ApiKeyStore;
let audit: AuditStore;
let adminId: string;
let memberId: string;
let adminToken: string;
let memberToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
apiKeys = createApiKeyStore(botDb.db);
const admin = await users.createUser("alice", "pw-alice", "admin");
const member = await users.createUser("bob", "pw-bob", "member");
adminId = admin.id;
memberId = member.id;
adminToken = sessions.createSession(adminId).token;
memberToken = sessions.createSession(memberId).token;
app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: false,
bots: "all",
permissions: {} as any,
}), apiKeys)
);
app.use("/api/keys", createApiKeysRouter(apiKeys, audit, { info: () => {}, warn: () => {}, error: () => {}, child: () => ({}) } as any));
});
afterEach(() => {
botDb.close();
});
const authed = (token: string) => {
const cookie = `${SESSION_COOKIE_NAME}=${token}`;
return {
get: (url: string) => request(app).get(url).set("Cookie", cookie),
post: (url: string) => request(app).post(url).set("Cookie", cookie),
delete: (url: string) => request(app).delete(url).set("Cookie", cookie),
};
};
const asAdmin = () => authed(adminToken);
const asMember = () => authed(memberToken);
it("lists only the caller's own keys", async () => {
apiKeys.create(adminId, "mine");
apiKeys.create(memberId, "theirs");
const res = await asAdmin().get("/api/keys");
expect(res.status).toBe(200);
expect(res.body.keys).toHaveLength(1);
expect(res.body.keys[0].name).toBe("mine");
expect(res.body.keys[0].rawKey).toBeUndefined();
});
it("creates a key and returns the plaintext exactly once", async () => {
const res = await asAdmin().post("/api/keys").send({ name: "ci" });
expect(res.status).toBe(201);
expect(res.body.rawKey).toMatch(/^tsmb_/);
expect(apiKeys.validateAndTouch(res.body.rawKey)?.userId).toBe(adminId);
// The list view never exposes the plaintext again.
const list = await asAdmin().get("/api/keys");
expect(JSON.stringify(list.body)).not.toContain(res.body.rawKey);
});
it("rejects creation without a valid name", async () => {
expect((await asAdmin().post("/api/keys").send({})).status).toBe(400);
expect((await asAdmin().post("/api/keys").send({ name: "" })).status).toBe(400);
expect((await asAdmin().post("/api/keys").send({ name: "x".repeat(65) })).status).toBe(400);
});
it("rejects creation beyond the per-user cap with 409", async () => {
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
apiKeys.create(memberId, `k${i}`);
}
const res = await asMember().post("/api/keys").send({ name: "overflow" });
expect(res.status).toBe(409);
});
it("deletes own key and it stops validating", async () => {
const { key } = apiKeys.create(memberId, "ci")!;
const res = await asMember().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(200);
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
});
it("a member cannot delete another user's key", async () => {
const { key } = apiKeys.create(adminId, "admin-key")!;
const res = await asMember().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(404);
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
});
it("an admin revoking another user's key audits that key's owner", async () => {
const { key } = apiKeys.create(memberId, "member-key")!;
const res = await asAdmin().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(200);
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
expect(audit.list(10, 0)).toEqual([
expect.objectContaining({
actorId: adminId,
actorUsername: "alice",
targetUserId: memberId,
targetUsername: "bob",
action: "api_key.deleted",
}),
]);
});
it("admin can list all keys with ?all=1, members cannot", async () => {
apiKeys.create(adminId, "a");
apiKeys.create(memberId, "b");
const adminAll = await asAdmin().get("/api/keys?all=1");
expect(adminAll.body.keys).toHaveLength(2);
expect(adminAll.body.keys.map((k: any) => k.username).sort()).toEqual(["alice", "bob"]);
const memberAll = await asMember().get("/api/keys?all=1");
expect(memberAll.body.keys).toHaveLength(1);
expect(memberAll.body.keys[0].name).toBe("b");
});
it("a request authenticated by an API key cannot manage keys", async () => {
const { rawKey } = apiKeys.create(adminId, "self-mgmt")!;
const res = await request(app)
.post("/api/keys")
.set("Authorization", `Bearer ${rawKey}`)
.send({ name: "proliferate" });
expect(res.status).toBe(403);
});
it("requires authentication", async () => {
expect((await request(app).get("/api/keys")).status).toBe(401);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { ApiKeyStore } from "../../data/api-keys.js";
import { MAX_API_KEYS_PER_USER } from "../../data/api-keys.js";
import type { AuditStore } from "../../data/audit.js";
import type { Logger } from "../../logger.js";
/**
* API-key management (list / create / revoke), mounted at /api/keys.
* Only interactive sessions may call these endpoints. Administrator keys
* retain user-management authority through /api/users.
*/
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
const router = Router();
const rejectApiKeyAuth = (req: Request, res: Response, next: NextFunction): void => {
if (req.authMethod === "api-key") {
res.status(403).json({ error: "API keys cannot manage API keys — log in to the WebUI" });
return;
}
next();
};
router.use(rejectApiKeyAuth);
// GET /api/keys — the caller's keys; admins may pass ?all=1 for every user's.
router.get("/", (req, res) => {
const user = req.user!;
if (req.query.all === "1" && user.role === "admin") {
res.json({ keys: apiKeys.listAll() });
return;
}
res.json({ keys: apiKeys.listForUser(user.id) });
});
// POST /api/keys — create a key; the plaintext is returned exactly once.
router.post("/", (req, res) => {
const user = req.user!;
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
if (!name || name.length > 64) {
res.status(400).json({ error: "name is required (1-64 characters)" });
return;
}
const created = apiKeys.create(user.id, name);
if (!created) {
res.status(409).json({ error: `每个用户最多创建 ${MAX_API_KEYS_PER_USER} 个 API Key` });
return;
}
try {
audit.record({
actorId: user.id,
actorUsername: user.username,
targetUserId: user.id,
targetUsername: user.username,
action: "api_key.created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "api_key.created" }, "audit insert failed");
}
logger.info({ userId: user.id, keyId: created.key.id }, "API key created");
res.status(201).json(created);
});
// DELETE /api/keys/:id — revoke; members only their own, admins any.
router.delete("/:id", (req, res) => {
const user = req.user!;
const key = apiKeys.findById(req.params.id);
if (!key || !apiKeys.delete(key.id, user.role === "admin" ? undefined : user.id)) {
res.status(404).json({ error: "API key not found" });
return;
}
try {
audit.record({
actorId: user.id,
actorUsername: user.username,
targetUserId: key.userId,
targetUsername: key.username,
action: "api_key.deleted",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "api_key.deleted" }, "audit insert failed");
}
logger.info({ userId: user.id, keyId: req.params.id }, "API key deleted");
res.json({ success: true });
});
return router;
}
+147 -2
View File
@@ -13,21 +13,30 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
import {
getDefaultConfig,
type BotConfig,
type JellyfinConfig,
type VoiceDuckingConfig,
} from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
/** Records live settings updates so the tests can assert per-bot propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
voiceDuckingCalls: [] as VoiceDuckingConfig[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
updateVoiceDucking(settings: VoiceDuckingConfig) {
this.voiceDuckingCalls.push({ ...settings });
},
};
}
@@ -84,6 +93,60 @@ describe("bot router /settings", () => {
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("GET /settings includes voiceDucking with safe defaults", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.voiceDucking).toEqual({ enabled: false, volumePercent: 30 });
});
it("POST /settings safely partial-merges, persists and hot-applies voiceDucking", async () => {
const enable = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: true } });
expect(enable.status).toBe(200);
expect(enable.body.voiceDucking).toEqual({ enabled: true, volumePercent: 30 });
const setVolume = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { volumePercent: 42.5 } });
expect(setVolume.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([
{ enabled: true, volumePercent: 30 },
{ enabled: true, volumePercent: 42.5 },
]);
}
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
});
it("POST /settings ignores malformed voiceDucking fields and non-object blocks", async () => {
config.voiceDucking = { enabled: true, volumePercent: 25 };
const invalidFields = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: "yes", volumePercent: 101 } });
expect(invalidFields.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
const arrayBlock = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: [{ enabled: false, volumePercent: 0 }] });
expect(arrayBlock.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([{ enabled: true, volumePercent: 25 }]);
}
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
@@ -325,6 +388,38 @@ describe("bot router /settings", () => {
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
@@ -647,4 +742,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
// 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();
});
});
+71 -1
View File
@@ -71,19 +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)) {
@@ -98,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;
@@ -175,6 +217,29 @@ export function createBotRouter(
);
}
// 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
@@ -208,17 +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,
});
});
+473 -3
View File
@@ -1,10 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import { createMusicRouter } from "./music.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: [] };
@@ -118,6 +128,25 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
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();
@@ -147,3 +176,444 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
expect(res.status).toBe(401);
});
});
describe("music router POST /quality — persistence (#125)", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let providers: Record<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);
});
});
describe("music router GET /artist/:id", () => {
function artistProvider(overrides: Record<string, unknown> = {}): MusicProvider {
return {
platform: "netease",
search: vi.fn().mockResolvedValue(empty),
getArtistDetail: vi.fn().mockResolvedValue({
id: "6452",
name: "Adele",
avatarUrl: "http://p/1.jpg",
platform: "netease",
description: "English singer",
}),
getArtistSongs: vi.fn().mockResolvedValue([
{ id: "1", name: "Hello", artist: "Adele", album: "25", duration: 295, coverUrl: "c", platform: "netease" },
]),
getArtistAlbums: vi.fn().mockResolvedValue([
{ id: "a1", name: "25", artist: "Adele", coverUrl: "c", songCount: 11, platform: "netease" },
]),
...overrides,
} as unknown as MusicProvider;
}
function mount(netease: MusicProvider, qq: MusicProvider = fakeProvider("qq")) {
const app = express();
app.use("/api/music", createMusicRouter(netease, qq, fakeProvider("bilibili"), pino({ level: "silent" })));
return app;
}
it("returns artist detail, hot songs and albums for the requested platform", async () => {
const netease = artistProvider();
const res = await request(mount(netease)).get("/api/music/artist/6452?platform=netease");
expect(res.status).toBe(200);
expect(res.body.artist).toMatchObject({ id: "6452", name: "Adele", description: "English singer" });
expect(res.body.songs).toHaveLength(1);
expect(res.body.albums).toHaveLength(1);
expect(netease.getArtistDetail).toHaveBeenCalledWith("6452");
expect(netease.getArtistSongs).toHaveBeenCalledWith("6452");
expect(netease.getArtistAlbums).toHaveBeenCalledWith("6452");
});
it("routes to the QQ provider when platform=qq", async () => {
const qq = artistProvider({ platform: "qq" });
const res = await request(mount(fakeProvider("netease"), qq)).get("/api/music/artist/abc?platform=qq");
expect(res.status).toBe(200);
expect(qq.getArtistDetail).toHaveBeenCalledWith("abc");
});
it("404s when the provider has no such artist", async () => {
const netease = artistProvider({ getArtistDetail: vi.fn().mockResolvedValue(null) });
const res = await request(mount(netease)).get("/api/music/artist/999");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Artist not found");
});
it("501s when the provider does not support artists at all", async () => {
const res = await request(mount(fakeProvider("netease"))).get("/api/music/artist/1");
expect(res.status).toBe(501);
expect(res.body.error).toBe("Not supported by this provider");
});
it("degrades each leg independently — a failing songs/albums call still returns the hero", async () => {
const netease = artistProvider({
getArtistSongs: vi.fn().mockRejectedValue(new Error("boom")),
getArtistAlbums: vi.fn().mockRejectedValue(new Error("boom")),
});
const res = await request(mount(netease)).get("/api/music/artist/6452");
expect(res.status).toBe(200);
expect(res.body.artist.name).toBe("Adele");
expect(res.body.songs).toEqual([]);
expect(res.body.albums).toEqual([]);
});
it("tolerates a provider that only implements getArtistDetail", async () => {
const netease = artistProvider({ getArtistSongs: undefined, getArtistAlbums: undefined });
const res = await request(mount(netease)).get("/api/music/artist/6452");
expect(res.status).toBe(200);
expect(res.body.songs).toEqual([]);
expect(res.body.albums).toEqual([]);
});
});
describe("music router GET /search/all artist aggregation", () => {
function searchProvider(platform: MusicProvider["platform"], artists: unknown[]): MusicProvider {
return {
platform,
search: vi.fn().mockResolvedValue({ ...empty, artists }),
} as unknown as MusicProvider;
}
it("merges artists from netease and qq and ignores sources without artists", async () => {
const app = express();
app.use(
"/api/music",
createMusicRouter(
searchProvider("netease", [{ id: "1", name: "N", avatarUrl: "", platform: "netease" }]),
searchProvider("qq", [{ id: "2", name: "Q", avatarUrl: "", platform: "qq" }]),
fakeProvider("bilibili"),
pino({ level: "silent" })
)
);
const res = await request(app).get("/api/music/search/all?q=adele");
expect(res.status).toBe(200);
expect(res.body.artists).toEqual([
{ id: "1", name: "N", avatarUrl: "", platform: "netease" },
{ id: "2", name: "Q", avatarUrl: "", platform: "qq" },
]);
});
});
+140 -9
View File
@@ -1,12 +1,62 @@
import express, { Router, type Response } from "express";
import type { MusicProvider, Song, Album } from "../../music/provider.js";
import type { MusicProvider, Song, Album, SearchResult } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
/**
* 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,
@@ -16,7 +66,10 @@ export function createMusicRouter(
config?: BotConfig,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
jellyfinProvider?: MusicProvider
jellyfinProvider?: MusicProvider,
// When set (alongside config), a quality change is persisted to config.json so
// it survives a restart (#125). Omitted by unit-test routers → no persistence.
configPath?: string,
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -62,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) {
@@ -148,7 +198,7 @@ export function createMusicRouter(
// 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 none: SearchResult = { 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),
@@ -176,8 +226,14 @@ export function createMusicRouter(
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
// Artists come only from the sources that model them (netease/qq); other
// providers simply contribute nothing.
const artists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.artists ?? [] : []),
...(qqResult.status === "fulfilled" ? qqResult.value.artists ?? [] : []),
];
res.json({ songs, albums, playlists });
res.json({ songs, albums, playlists, artists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
@@ -247,6 +303,36 @@ export function createMusicRouter(
}
});
router.get("/artist/:id", async (req, res) => {
try {
const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getArtistDetail) {
res.status(501).json({ error: "Not supported by this provider" });
return;
}
// Each piece degrades independently: a source that cannot list albums (or
// a transient upstream failure) must not take the hero or the songs down
// with it, so every call falls back to an empty value.
const [artist, songs, albums] = await Promise.all([
provider.getArtistDetail(req.params.id).catch(() => null),
provider.getArtistSongs
? provider.getArtistSongs(req.params.id).catch(() => [] as Song[])
: Promise.resolve([] as Song[]),
provider.getArtistAlbums
? provider.getArtistAlbums(req.params.id).catch(() => [] as Album[])
: Promise.resolve([] as Album[]),
]);
if (!artist) {
res.status(404).json({ error: "Artist not found" });
return;
}
res.json({ artist, songs, albums });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try {
const provider = resolveProvider(req.query.platform, res);
@@ -332,6 +418,31 @@ export function createMusicRouter(
}
});
// B站分P列表查询
router.get("/bilibili/parts", async (req, res) => {
try {
const bvid = (req.query.bvid as string)?.trim();
if (!bvid) {
res.status(400).json({ error: "bvid is required" });
return;
}
const provider = bilibiliProvider as any;
if (typeof provider.getVideoParts === "function") {
const result = await provider.getVideoParts(bvid);
if (!result) {
res.status(404).json({ error: "Video not found" });
return;
}
res.json(result);
} else {
res.status(501).json({ error: "Not supported" });
}
} catch (err) {
logger.error({ err }, "Get bilibili parts failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
@@ -480,6 +591,26 @@ export function createMusicRouter(
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
jellyfinProvider.setQuality(quality);
}
// Persist the (post-apply) per-provider quality so it survives a restart
// (#125). Snapshotting each provider's getQuality() AFTER setQuality captures
// exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps
// aliases), so replaying these on startup reproduces this state faithfully.
if (config && configPath) {
config.audioQuality = {
netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou,
jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin,
};
try {
saveConfig(configPath, config);
} catch (err) {
logger.warn({ err }, "Failed to persist audio quality");
}
}
logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality });
});
+3 -1
View File
@@ -374,13 +374,15 @@ describe("guest enforcement on player routes", () => {
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
});
it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => {
it("playCollection flag gates /play-playlist, /play-album, /play-artist (issue #103)", async () => {
const allow = mountGuest({ playCollection: true });
const deny = mountGuest({ playCollection: false });
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).not.toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-artist`).send({ artistId: "1" })).status).toBe(403);
// playCollection does NOT leak into the destructive single-song / queue ops.
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createDatabase } from "../../data/database.js";
import { createPersonalMusicRouter } from "./personal-music.js";
import { createPlayerRouter } from "./player.js";
function mount() {
const db = createDatabase(":memory:");
db.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run("u1", "alice", "x", 0, 0, "member");
const personalView = {
getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })),
};
const provider: any = {
platform: "netease",
getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })),
pollQrLogin: vi.fn(async () => ({ status: "waiting" })),
withCookie: vi.fn(() => personalView),
setCookie: vi.fn(),
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: "u1", username: "alice", role: "member" };
next();
});
app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" })));
return { app, db, provider, personalView };
}
describe("personal music account router (#164)", () => {
it("reports not linked until the user logs in", async () => {
const { app } = mount();
const res = await request(app).get("/api/me/music/netease/status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ linked: false, loggedIn: false });
});
it("creates a QR code", async () => {
const { app } = mount();
const res = await request(app).post("/api/me/music/netease/qrcode");
expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" });
});
it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => {
const { app, db, provider } = mount();
provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" });
const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" });
expect(res.body).toEqual({ status: "confirmed" });
expect(JSON.stringify(res.body)).not.toContain("MUSIC_U");
expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice");
expect(provider.setCookie).not.toHaveBeenCalled();
});
it("requires a key to poll", async () => {
const { app } = mount();
expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400);
});
it("reports the linked account's nickname via a view on the user's cookie", async () => {
const { app, db, provider } = mount();
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
const res = await request(app).get("/api/me/music/netease/status");
expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" });
expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
});
it("unlinks", async () => {
const { app, db } = mount();
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
expect((await request(app).delete("/api/me/music/netease")).status).toBe(200);
expect(db.getUserMusicCookie("u1", "netease")).toBeNull();
});
});
describe("web FM uses the caller's linked NetEase account (#164)", () => {
async function startFm(opts: { linked: boolean; role?: string; platform?: string }) {
const db = createDatabase(":memory:");
db.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run("u1", "alice", "x", 0, 0, "member");
if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
const personal = { platform: "netease", personal: true };
const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) };
const qq: any = { platform: "qq" };
const bot = {
id: "b1",
getProviderFor: (p: string) => (p === "qq" ? qq : shared),
startFm: vi.fn(async (_provider: unknown) => "Personal FM started"),
};
const botManager: any = { getBot: () => bot };
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = {
id: "u1", username: "alice", role: opts.role ?? "member",
capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true },
};
next();
});
app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db));
const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" });
return { res, bot, shared, personal, qq };
}
it("starts FM on the user's own account when linked", async () => {
const { res, bot, shared, personal } = await startFm({ linked: true });
expect(res.status).toBe(200);
expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
expect(bot.startFm.mock.calls[0][0]).toBe(personal);
});
it("falls back to the shared account when the user has not linked one", async () => {
const { bot, shared } = await startFm({ linked: false });
expect(bot.startFm.mock.calls[0][0]).toBe(shared);
});
it("leaves other platforms alone", async () => {
const { bot, qq } = await startFm({ linked: true, platform: "qq" });
expect(bot.startFm.mock.calls[0][0]).toBe(qq);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider, QrCodeResult } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
/**
* A provider that can log a web user into their OWN account without touching
* the bot's shared login, and hand out a view bound to that account (#164).
*/
export interface PersonalLoginProvider {
getQrCode(): Promise<QrCodeResult>;
pollQrLogin(key: string): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }>;
withCookie(cookie: string): MusicProvider;
}
export function supportsPersonalLogin(
provider: MusicProvider | undefined,
): provider is MusicProvider & PersonalLoginProvider {
const p = provider as Partial<PersonalLoginProvider> | undefined;
return typeof p?.pollQrLogin === "function" && typeof p.withCookie === "function";
}
/**
* The caller's own NetEase account, used for their personal FM instead of the
* bot's shared login (#164). Every route acts on req.user only; the cookie is
* stored server-side and never sent back to the browser.
*/
export function createPersonalMusicRouter(
database: BotDatabase,
neteaseProvider: MusicProvider,
logger: Logger,
): Router {
const router = Router();
const platform = "netease";
router.use((_req, res, next) => {
if (!supportsPersonalLogin(neteaseProvider)) {
res.status(501).json({ error: "Personal login not supported" });
return;
}
next();
});
const provider = neteaseProvider as MusicProvider & PersonalLoginProvider;
router.get("/netease/status", async (req, res) => {
const cookie = database.getUserMusicCookie(req.user!.id, platform);
if (!cookie) {
res.json({ linked: false, loggedIn: false });
return;
}
try {
const status = await provider.withCookie(cookie).getAuthStatus();
res.json({ linked: true, ...status });
} catch (err) {
logger.warn({ err }, "Personal NetEase status check failed");
res.json({ linked: true, loggedIn: false });
}
});
router.post("/netease/qrcode", async (_req, res) => {
try {
res.json(await provider.getQrCode());
} catch (err) {
logger.error({ err }, "Personal NetEase QR generation failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/netease/qrcode/status", async (req, res) => {
const key = req.query.key;
if (typeof key !== "string" || !key) {
res.status(400).json({ error: "key is required" });
return;
}
try {
const { status, cookie } = await provider.pollQrLogin(key);
if (status === "confirmed" && cookie) {
database.setUserMusicCookie(req.user!.id, platform, cookie);
logger.info({ userId: req.user!.id, platform }, "Personal music account linked");
}
res.json({ status });
} catch (err) {
logger.error({ err }, "Personal NetEase QR status check failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.delete("/netease", (req, res) => {
database.deleteUserMusicCookie(req.user!.id, platform);
logger.info({ userId: req.user!.id, platform }, "Personal music account unlinked");
res.json({ ok: true });
});
return router;
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { collectArtistSongs } from "./player.js";
import type { ArtistSongPage, Song } from "../../music/provider.js";
import express from "express";
import request from "supertest";
import { createPlayerRouter } from "./player.js";
import { BotInstance } from "../../bot/instance.js";
import { PlayQueue } from "../../audio/queue.js";
function song(id: string): Song {
return {
id,
name: `song-${id}`,
artist: "Adele",
album: "25",
duration: 200,
coverUrl: "c",
platform: "netease",
};
}
function page(ids: string[], total: number, hasMore: boolean): ArtistSongPage {
return { songs: ids.map(song), total, hasMore };
}
describe("collectArtistSongs (play-artist all:true)", () => {
it("walks every page until hasMore is false and de-duplicates ids", async () => {
const pages: Record<number, ArtistSongPage> = {
0: page(["1", "2"], 4, true),
100: page(["2", "3"], 4, true),
200: page(["4"], 4, false),
};
const fetchPage = vi.fn(async (_id: string, offset = 0, _limit = 100) => pages[offset] ?? page([], 4, false));
const songs = await collectArtistSongs(fetchPage as any, "artist-1");
expect(songs.map((s) => s.id)).toEqual(["1", "2", "3", "4"]);
expect(fetchPage.mock.calls.map((c) => c[1])).toEqual([0, 100, 200]);
expect(fetchPage.mock.calls[0][2]).toBe(100);
});
it("stops at the 500-track safety cap", async () => {
let n = 0;
const fetchPage = vi.fn(async () => ({
songs: Array.from({ length: 100 }, () => song(String(n++))),
total: 100000,
hasMore: true,
}));
const songs = await collectArtistSongs(fetchPage as any, "a");
expect(songs).toHaveLength(500);
expect(fetchPage).toHaveBeenCalledTimes(5);
});
it("enforces the song cap even when an upstream page exceeds the requested limit", async () => {
const fetchPage = vi.fn(async () => page(Array.from({ length: 600 }, (_, i) => String(i)), 600, false));
expect(await collectArtistSongs(fetchPage, "a")).toHaveLength(500);
});
it("stops on an empty page even when hasMore claims otherwise", async () => {
const fetchPage = vi.fn(async () => page([], 9, true));
expect(await collectArtistSongs(fetchPage as any, "a")).toEqual([]);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
it("returns the first page unchanged when it is already complete", async () => {
const fetchPage = vi.fn(async () => page(["1"], 1, false));
const songs = await collectArtistSongs(fetchPage as any, "a");
expect(songs.map((s) => s.id)).toEqual(["1"]);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
});
describe("play-artist playback serialization", () => {
it("keeps the queue and audible song consistent when single-song playback overlaps artist playback", async () => {
const queue = new PlayQueue();
let audible: string | null = null;
let releaseArtist!: () => void;
let notifyArtistStarted!: () => void;
let notifySingleArrived!: () => void;
const artistStarted = new Promise<void>((resolve) => { notifyArtistStarted = resolve; });
const artistHold = new Promise<void>((resolve) => { releaseArtist = resolve; });
const singleArrived = new Promise<void>((resolve) => { notifySingleArrived = resolve; });
const bot: any = {
playGate: Promise.resolve(),
getProviderFor: () => ({ platform: "netease", getArtistSongs: async () => [song("A")], getArtistAllSongs: async () => page(["A"], 1, false) }),
getPlayer: () => ({ stop: () => { audible = null; }, resetFailures: () => {} }),
getQueueManager: () => queue,
resolveAndPlay: async (track: Song) => {
notifyArtistStarted();
await artistHold;
audible = track.id;
return true;
},
playSingleSong: async (track: Song) => {
queue.clear();
queue.add(track);
queue.play();
audible = track.id;
return true;
},
};
bot.runExclusive = (fn: () => Promise<unknown>) => BotInstance.prototype.runExclusive.call(bot, fn);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = { role: "admin" }; next(); });
app.use("/api/player/b/play-song", (_req, _res, next) => { notifySingleArrived(); next(); });
app.use("/api/player", createPlayerRouter({ getBot: () => bot } as any, { error: vi.fn() } as any));
const artistRequest = request(app).post("/api/player/b/play-artist").send({ artistId: "artist", platform: "netease" }).then((res) => res);
await artistStarted;
const singleRequest = request(app).post("/api/player/b/play-song").send({ song: song("B") }).then((res) => res);
// Let the overlapping HTTP request enter the real route while A's URL is pending.
await singleArrived;
await Promise.resolve();
await Promise.resolve();
const whileArtistPending = queue.current()?.id;
releaseArtist();
const [artistResponse, singleResponse] = await Promise.all([artistRequest, singleRequest]);
expect(artistResponse.status).toBe(200);
expect(singleResponse.status).toBe(200);
expect(whileArtistPending).toBe("A");
expect(queue.current()?.id).toBe("B");
expect(audible).toBe("B");
});
});
+145 -20
View File
@@ -1,11 +1,38 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js";
import type { MusicProvider, Song, ArtistSongPage } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
import { requireBotAccess } from "../middleware/requirePermission.js";
import { authorize } from "../middleware/authorize.js";
import { supportsPersonalLogin } from "./personal-music.js";
/** Hard cap on how many tracks one "播放全部" request may queue — a safety net
* against a pathological catalogue (and against an upstream paging bug). */
const MAX_ARTIST_QUEUE = 500;
const ARTIST_QUEUE_PAGE = 100;
/** Walks every page of an artist's catalogue (best-first, de-duplicated). */
export async function collectArtistSongs(
fetchPage: (artistId: string, offset?: number, limit?: number) => Promise<ArtistSongPage>,
artistId: string
): Promise<Song[]> {
const songs: Song[] = [];
const seen = new Set<string>();
for (let offset = 0; offset < MAX_ARTIST_QUEUE; offset += ARTIST_QUEUE_PAGE) {
const page = await fetchPage(artistId, offset, ARTIST_QUEUE_PAGE);
for (const song of page.songs) {
if (!seen.has(song.id)) {
seen.add(song.id);
songs.push(song);
if (songs.length === MAX_ARTIST_QUEUE) return songs;
}
}
if (!page.hasMore || page.songs.length === 0) break;
}
return songs;
}
export function createPlayerRouter(
botManager: BotManager,
@@ -124,11 +151,19 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
let provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
// A signed-in user who linked their own NetEase account gets FM from
// THEIR taste, not the bot's shared login (#164). Songs still resolve
// through the shared provider when played.
const user = (req as any).user;
if (provider.platform === "netease" && user && user.role !== "guest" && database) {
const cookie = database.getUserMusicCookie(user.id, "netease");
if (cookie && supportsPersonalLogin(provider)) provider = provider.withCookie(cookie);
}
const message = await bot.startFm(provider, requesterName(req));
res.json({
ok:
@@ -461,7 +496,104 @@ export function createPlayerRouter(
}
});
// Play a single song by ID — resolves URL on demand
// Play an artist's songs. An artist page queues the singer's FULL catalogue —
// never just the hot 50 — so this pages through getArtistAllSongs when the
// source can page a catalogue, and falls back to getArtistSongs (hot songs)
// when it cannot.
router.post("/:botId/play-artist", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => {
try {
const bot = (req as any).bot;
const { artistId, platform } = req.body;
if (!artistId) {
res.status(400).json({ error: "artistId is required" });
return;
}
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
if (typeof provider.getArtistSongs !== "function") {
res.status(501).json({ error: "Not supported by this provider" });
return;
}
// Whole catalogue when the source can page it (bounded by the collector's
// safety cap); otherwise the hot songs are the best it can offer.
const fetchPage = provider.getArtistAllSongs?.bind(provider);
const songs = fetchPage
? await collectArtistSongs(fetchPage, artistId)
: await provider.getArtistSongs(artistId, 50);
if (songs.length === 0) {
res.json({ ok: false, message: "该歌手暂无可用歌曲" });
return;
}
// Same QQ batch-resolve optimization as play-album: drop tracks that are
// region/copyright blocked instead of burning retries on them.
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;
}
// Catalogue and copyright lookups leave current playback running. Only
// the queue replacement and playback itself occupy the shared play gate.
const body = await bot.runExclusive(async () => {
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
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} 首`;
return started && playing
? { ok: true, message: `${loadedMsg},正在播放:${playing.name}` }
: { ok: false, message: `${loadedMsg},但无法开始播放。` };
});
res.json(body);
} catch (err) {
logger.error({ err }, "play-artist failed");
res.status(500).json({ error: (err as Error).message });
}
});
// 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;
@@ -474,23 +606,16 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res);
return;
}
const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear();
queue.add({ ...song, requestedBy: requesterName(req) });
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 });
}
+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();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import { SHARED_QUEUE_OWNER } from "../../data/database.js";
import type { BotManager } from "../../bot/manager.js";
import type { Logger } from "../../logger.js";
/**
* The /api/saved-queues router (Feature 1, #119). Named save/load of queues,
* per-user with a reserved shared bucket. Every route is inert (403) unless
* savedQueuesEnabled is on, so the feature is fully gated behind the admin flag.
*
* Ownership model:
* - WebUI save with `shared:true` → SHARED_QUEUE_OWNER; otherwise the caller's
* own user id (private to them).
* - list returns the caller's own queues + shared ones.
* - load/delete are allowed only for the caller's own queues or shared ones;
* another user's private queue 404s (no existence leak, matching favorites).
*/
export function createSavedQueuesRouter(
database: BotDatabase,
botManager: BotManager,
isEnabled: () => boolean,
logger: Logger,
): Router {
const router = Router();
// Feature gate — inert (403) when savedQueuesEnabled is false.
router.use((_req, res, next) => {
if (!isEnabled()) {
res.status(403).json({ error: "此功能未启用" });
return;
}
next();
});
// GET / — the caller's own + shared saved queues (meta only, no songs blob).
router.get("/", (req, res) => {
const userId = req.user!.id;
res.json({ queues: database.listSavedQueues(userId, true) });
});
// POST / — snapshot a bot's CURRENT queue and upsert it.
// body: { botId, name, shared? }
router.post("/", (req, res) => {
const userId = req.user!.id;
const { botId, name, shared } = req.body ?? {};
if (typeof name !== "string" || !name.trim() || typeof botId !== "string" || !botId) {
res.status(400).json({ error: "botId and name are required" });
return;
}
const bot = botManager.getBot(botId);
if (!bot) {
res.status(404).json({ error: "bot not found" });
return;
}
const songs = bot.getQueueManager().list();
if (songs.length === 0) {
res.status(400).json({ error: "队列为空,无法保存" });
return;
}
const ownerId = shared === true ? SHARED_QUEUE_OWNER : userId;
try {
const saved = database.saveQueue(ownerId, name.trim(), songs);
logger.info({ userId, ownerId, name: saved.name, count: saved.songCount }, "saved queue upserted");
res.json({
queue: {
id: saved.id,
ownerId: saved.ownerId,
name: saved.name,
songCount: saved.songCount,
},
});
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
// POST /:id/load — load a saved queue into a bot. body: { botId, mode }
router.post("/:id/load", async (req, res) => {
const userId = req.user!.id;
const username = req.user!.username;
const id = parseInt(req.params.id, 10);
const { botId, mode } = req.body ?? {};
if (Number.isNaN(id) || typeof botId !== "string" || !botId) {
res.status(400).json({ error: "invalid id/botId" });
return;
}
const sq = database.getSavedQueue(id);
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
res.status(404).json({ error: "not found" });
return;
}
const bot = botManager.getBot(botId);
if (!bot) {
res.status(404).json({ error: "bot not found" });
return;
}
const loadMode = mode === "append" ? "append" : "replace";
await bot.loadSavedQueue(sq.songs, loadMode, username || "游客");
res.json({ ok: true, loaded: sq.songs.length, mode: loadMode });
});
// DELETE /:id — delete a saved queue (own or shared only).
router.delete("/:id", (req, res) => {
const userId = req.user!.id;
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
res.status(400).json({ error: "invalid id" });
return;
}
const sq = database.getSavedQueue(id);
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
res.status(404).json({ error: "not found" });
return;
}
database.deleteSavedQueue(id);
logger.info({ userId, id }, "saved queue deleted");
res.json({ ok: true });
});
return router;
}
+72 -2
View File
@@ -6,6 +6,7 @@ import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createApiKeyStore, type ApiKeyStore } from "../../data/api-keys.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
@@ -13,7 +14,7 @@ import type { GuestPermissions, BotAccess } from "../../data/permissions.js";
import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore, apiKeys?: ApiKeyStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
@@ -27,7 +28,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
audit,
pino({ level: "silent" }),
permissions,
() => getDefaultConfig().guestMode
() => getDefaultConfig().guestMode,
apiKeys
)
);
return app;
@@ -167,6 +169,74 @@ describe("session router", () => {
expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id);
// 20s, not the 5s default: this case runs SIX bcryptjs rounds (one hash to
// create the user, four verifies, one hash for the new password), and
// bcryptjs is pure JS. It takes ~4.5s on an idle machine — close enough to
// the default that it tipped over whenever the full suite saturated the
// CPU, which made it look like a real intermittent failure. The work is
// genuinely slow, not hung, so the timeout is what was wrong.
}, 20000);
});
describe("session router — API key revocation", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let apiKeys: ApiKeyStore;
let app: express.Express;
let userId: string;
let currentCookie: string;
let rawKey: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
apiKeys = createApiKeyStore(botDb.db);
const member = await users.createUser("alice", "old-password", "member");
userId = member.id;
currentCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
rawKey = apiKeys.create(userId, "integration")!.rawKey;
app = makeApp(botDb, users, sessions, apiKeys);
});
afterEach(() => botDb.close());
it("successful password change revokes all owned keys and preserves only the active browser session", async () => {
const secondKey = apiKeys.create(userId, "another-integration")!.rawKey;
const otherSession = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
const otherUser = await users.createUser("bob", "other-password", "member");
const otherUserKey = apiKeys.create(otherUser.id, "other-user-integration")!.rawKey;
const changed = await request(app).post("/api/session/change-password")
.set("Cookie", currentCookie)
.send({ oldPassword: "old-password", newPassword: "new-password" });
expect(changed.status).toBe(204);
expect(apiKeys.validateAndTouch(rawKey)).toBeNull();
expect(apiKeys.validateAndTouch(secondKey)).toBeNull();
expect(apiKeys.listForUser(userId)).toEqual([]);
expect(apiKeys.validateAndTouch(otherUserKey)?.userId).toBe(otherUser.id);
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
expect((await request(app).get("/api/session/me").set("Cookie", otherSession)).status).toBe(401);
}, 20_000);
it.each([
{ oldPassword: "wrong-password", newPassword: "new-password", status: 401 },
{ oldPassword: "old-password", newPassword: "short", status: 400 },
])("failed password change ($status) leaves API keys valid", async ({ oldPassword, newPassword, status }) => {
const changed = await request(app).post("/api/session/change-password")
.set("Cookie", currentCookie)
.send({ oldPassword, newPassword });
expect(changed.status).toBe(status);
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
});
it("unauthenticated password change leaves API keys valid", async () => {
const changed = await request(app).post("/api/session/change-password")
.send({ oldPassword: "old-password", newPassword: "new-password" });
expect(changed.status).toBe(401);
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
});
});
+4 -1
View File
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { ApiKeyStore } from "../../data/api-keys.js";
import type { AuditStore } from "../../data/audit.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js";
@@ -54,7 +55,8 @@ export function createSessionRouter(
audit: AuditStore,
logger: Logger,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
getGuestConfig: () => GuestModeConfig,
apiKeys?: ApiKeyStore
): Router {
const router = Router();
@@ -202,6 +204,7 @@ export function createSessionRouter(
await users.changePassword(u.id, newPassword);
const currentToken = parseTokenFromCookie(req.headers.cookie);
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
apiKeys?.deleteAllForUser(u.id);
try {
audit.record({
actorId: u.id, actorUsername: u.username,
+7 -1
View File
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { ApiKeyStore } from "../../data/api-keys.js";
import type { AuditStore } from "../../data/audit.js";
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
import { extractSessionToken } from "../auth/validateSession.js";
@@ -20,7 +21,8 @@ export function createUsersRouter(
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore
permissions: PermissionStore,
apiKeys?: ApiKeyStore
): Router {
const router = Router();
@@ -85,6 +87,7 @@ export function createUsersRouter(
}
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
sessions.deleteAllForUser(targetId);
apiKeys?.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
@@ -117,6 +120,9 @@ export function createUsersRouter(
? (extractSessionToken(req.headers.cookie) ?? undefined)
: undefined;
sessions.deleteAllForUser(targetId, exceptToken);
// A password reset must also kill the target's API keys — they are
// long-lived credentials that otherwise survive credential rotation.
apiKeys?.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
+36
View File
@@ -0,0 +1,36 @@
import type { Request } from "express";
import { SESSION_COOKIE_NAME } from "./validateSession.js";
/**
* Extract a raw API key from the `X-API-Key` header or an
* `Authorization: Bearer <key>` header. Returns null when neither is present.
*/
export function extractApiKey(req: Request): string | null {
const header = req.headers["x-api-key"];
if (typeof header === "string" && header.trim()) {
return header.trim();
}
const auth = req.headers.authorization;
if (typeof auth === "string") {
const match = /^bearer\s+(.+)$/i.exec(auth);
if (match) {
const key = match[1].trim();
if (key) return key;
}
}
return null;
}
export function hasApiKeyCredential(req: Request): boolean {
return extractApiKey(req) !== null;
}
/**
* API-key clients (no session cookie) skip the origin check entirely. Requests
* that ALSO carry the session cookie must NOT rely on this — an attacker page
* can set arbitrary headers while the victim's cookie rides along ambiently,
* so the cookie keeps the request under the origin check.
*/
export function isApiKeyOnlyRequest(req: Request): boolean {
return hasApiKeyCredential(req) && !req.headers.cookie?.includes(`${SESSION_COOKIE_NAME}=`);
}
+24
View File
@@ -70,4 +70,28 @@ describe("csrfOriginCheck middleware", () => {
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
// API-key clients authenticate via a header the browser never attaches
// automatically, so CSRF cannot abuse them — the origin check is skipped.
it("allows POST with an X-API-Key header and no session cookie", async () => {
const res = await request(app).post("/").set("X-API-Key", "tsmb_abc");
expect(res.status).toBe(200);
});
it("allows POST with an Authorization: Bearer key and no session cookie", async () => {
const res = await request(app).post("/").set("Authorization", "Bearer tsmb_abc");
expect(res.status).toBe(200);
});
it("does NOT skip the origin check when a session cookie rides along with an API key", async () => {
// An attacker page can set arbitrary headers while the victim's cookie is
// attached ambiently — the cookie keeps the request under the gate.
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://evil.com")
.set("Cookie", "tsmb_session=whatever")
.set("X-API-Key", "tsmb_abc");
expect(res.status).toBe(403);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from "express";
import { isApiKeyOnlyRequest } from "../auth/api-key-header.js";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -10,7 +11,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
* this header check covers the remaining attack surface.
*/
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
if (SAFE_METHODS.has(req.method) || isApiKeyOnlyRequest(req)) {
next();
return;
}
+110
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createApiKeyStore } from "../../data/api-keys.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -114,3 +115,112 @@ describe("requireAuth middleware", () => {
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
});
});
describe("requireAuth middleware with API keys", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminKey: string;
let memberKey: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const apiKeys = createApiKeyStore(botDb.db);
const admin = await users.createUser("alice", "pw-alice", "admin");
const member = await users.createUser("bob", "pw-bob", "member");
permissions.setPermissions(member.id, { capabilities: ["player.control"], bots: ["bot1"] });
adminKey = apiKeys.create(admin.id, "ci")!.rawKey;
memberKey = apiKeys.create(member.id, "deploy")!.rawKey;
app = express();
app.use(cookieParser());
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: false,
bots: "all",
permissions: {} as any,
}), apiKeys)
);
app.get("/protected", (req, res) => {
const u: any = (req as any).user;
res.json({
ok: true,
authMethod: (req as any).authMethod,
user: u
? {
username: u.username,
role: u.role,
capabilities: u.capabilities ? [...u.capabilities] : [],
bots: u.bots === "all" ? "all" : [...(u.bots ?? [])],
}
: null,
});
});
});
afterEach(() => {
botDb.close();
});
it("authenticates a valid X-API-Key header and attaches the owner user", async () => {
const res = await request(app).get("/protected").set("X-API-Key", adminKey);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
expect(res.body.authMethod).toBe("api-key");
});
it("authenticates an Authorization: Bearer key", async () => {
const res = await request(app).get("/protected").set("Authorization", `Bearer ${adminKey}`);
expect(res.status).toBe(200);
expect(res.body.user.username).toBe("alice");
});
it("rejects an unknown key with 401", async () => {
const res = await request(app).get("/protected").set("X-API-Key", "tsmb_bogus");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid api key" });
});
it("ignores the session cookie when a key header is present", async () => {
// Garbage cookie + valid key → key wins.
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`)
.set("X-API-Key", memberKey);
expect(res.status).toBe(200);
expect(res.body.user.username).toBe("bob");
});
it("a member key inherits the member's capabilities and bot scope", async () => {
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
expect(res.status).toBe(200);
expect(res.body.user.role).toBe("member");
expect(res.body.user.capabilities).toContain("player.control");
expect(res.body.user.bots).toContain("bot1");
expect(res.body.user.capabilities).not.toContain("bot.manage");
});
it("returns 401 when a key header is present but no store is wired", async () => {
const sessions: any = { validateAndTouch: () => null };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const mw = createRequireAuth(sessions, permissions, () => ({ enabled: false, bots: "all", permissions: {} as any }));
const req: any = { headers: { "x-api-key": "tsmb_x" } };
const res: any = { status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
const next = vi.fn();
mw(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it("a key whose owner was deleted stops working", async () => {
const users = createUserStore(botDb.db);
const member = users.findByUsername("bob")!;
users.deleteUser(member.id);
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
expect(res.status).toBe(401);
});
});
+31 -1
View File
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import type { ApiKeyStore } from "../../data/api-keys.js";
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
import type { GuestModeConfig } from "../../data/config.js";
import {
@@ -8,6 +9,7 @@ import {
extractSessionToken,
SESSION_COOKIE_NAME,
} from "../auth/validateSession.js";
import { extractApiKey } from "../auth/api-key-header.js";
declare module "express-serve-static-core" {
interface Request {
@@ -19,15 +21,42 @@ declare module "express-serve-static-core" {
bots?: "all" | Set<string>;
guest?: GuestPermissions;
};
/** How this request authenticated: browser session cookie or API key. */
authMethod?: "session" | "api-key";
}
}
export function createRequireAuth(
sessions: SessionStore,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
getGuestConfig: () => GuestModeConfig,
apiKeys?: ApiKeyStore
): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
// ─── API-key path ──────────────────────────────────────────────────────
// A key in a header authenticates on its own; cookies are ignored on this
// path so the two credential types can never be mixed.
const rawKey = extractApiKey(req);
if (rawKey !== null) {
const validation = apiKeys?.validateAndTouch(rawKey) ?? null;
if (!validation) {
res.status(401).json({ error: "invalid api key" });
return;
}
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
req.user = {
id: validation.userId,
username: validation.username,
role: validation.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
};
req.authMethod = "api-key";
next();
return;
}
// ─── Session-cookie path (browser) ─────────────────────────────────────
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
@@ -56,6 +85,7 @@ export function createRequireAuth(
bots: ctx.bots,
guest: ctx.guest,
};
req.authMethod = "session";
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Search-engine hardening for issue #128: searching "TsmusicBot" surfaced a
* large number of deployed instances' WebUI URLs, letting strangers walk into
* other people's control pages. The fix is defence in depth — none of these
* layers is authentication (that's handled elsewhere), they just keep the
* public URL out of crawler indexes:
*
* 1. `X-Robots-Tag: noindex, nofollow` on EVERY response;
* 2. `GET /robots.txt` → `User-agent: * / Disallow: /`;
* 3. `<meta name="robots" content="noindex, nofollow">` in web/index.html.
*
* The header middleware and the /robots.txt route both live at the top of
* `createWebServer` in `server.ts`; this test asserts the exact behaviour we
* expect from them in isolation (the wiring inside server.ts is verified by
* code review / git diff, matching security-headers.test.ts).
*/
describe("search-engine hardening (issue #128 noindex)", () => {
function buildApp() {
const app = express();
// Mirrors the security-headers middleware in server.ts.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next();
});
// Mirrors the public /robots.txt route in server.ts.
app.get("/robots.txt", (_req, res) => {
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
});
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/api/session/login", (_req, res) => res.json({ ok: true }));
return app;
}
it("sets X-Robots-Tag: noindex, nofollow on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.status).toBe(200);
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
it("sets X-Robots-Tag on POST (API) responses too", async () => {
const res = await request(buildApp()).post("/api/session/login");
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
it("serves /robots.txt disallowing all crawlers", async () => {
const res = await request(buildApp()).get("/robots.txt");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/text\/plain/);
expect(res.text).toContain("User-agent: *");
expect(res.text).toContain("Disallow: /");
});
it("still tags the /robots.txt response itself as noindex", async () => {
const res = await request(buildApp()).get("/robots.txt");
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
});
describe("frontend robots meta tag (issue #128 noindex)", () => {
const indexHtmlPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../web/index.html"
);
const html = fs.readFileSync(indexHtmlPath, "utf-8");
const robotsMeta = html.match(
/<meta\s+name=["']robots["']\s+content=["']([^"']+)["']\s*\/?>/i
);
it("declares a robots meta tag", () => {
expect(robotsMeta).not.toBeNull();
});
it("marks the SPA shell noindex, nofollow (covers /bot/<id> dedicated links)", () => {
expect(robotsMeta?.[1]).toBe("noindex, nofollow");
});
});
+49 -7
View File
@@ -19,7 +19,10 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { createPersonalMusicRouter } from "./api/personal-music.js";
import { createSavedQueuesRouter } from "./api/saved-queues.js";
import { createSpotifyRouter } from "./api/spotify.js";
import { createApiKeysRouter } from "./api/api-keys.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { SpotifyProvider } from "../music/spotify/provider.js";
import type { JellyfinProvider } from "../music/jellyfin.js";
@@ -31,6 +34,7 @@ import {
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { createApiKeyStore } from "../data/api-keys.js";
import { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
@@ -77,12 +81,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.set("trust proxy", true);
}
// Security headers: prevent the WebUI from being embedded in a third-party
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
// of X-Frame-Options; both are set for compatibility across browsers.
// Security headers:
// • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being
// embedded in a third-party iframe (clickjacking defence). CSP
// frame-ancestors is the modern equivalent of X-Frame-Options; both are
// set for compatibility across browsers.
// • X-Robots-Tag — keep deployed instances out of search-engine indexes
// (issue #128: searching "TsmusicBot" surfaced strangers' WebUI URLs).
// Set on EVERY response so JSON/API responses and the SPA shell are all
// covered; complements /robots.txt and the <meta name="robots"> tag.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next();
});
@@ -93,8 +104,16 @@ export function createWebServer(options: WebServerOptions): WebServer {
const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
const permissions = createPermissionStore(options.database.db);
const apiKeys = createApiKeyStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
// Disallow every crawler (issue #128). Declared before the static SPA
// fallback so this wins over index.html for /robots.txt. Belt-and-braces
// with the X-Robots-Tag header above and the <meta name="robots"> tag.
app.get("/robots.txt", (_req, res) => {
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
});
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
@@ -112,10 +131,10 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api/session/login", loginLimit);
app.use("/api/session/setup", setupLimit);
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode, apiKeys));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
@@ -151,7 +170,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.configPath)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
@@ -188,11 +207,34 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
}
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
// The caller's own NetEase login for their personal FM (#164). Guests share
// one anonymous identity, so they cannot link an account.
app.use(
"/api/me/music",
requireNotGuest,
createPersonalMusicRouter(options.database, options.neteaseProvider, logger),
);
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
// the router itself 403s every route unless savedQueuesEnabled is on.
app.use(
"/api/saved-queues",
requireNotGuest,
createSavedQueuesRouter(
options.database,
options.botManager,
() => options.config.savedQueuesEnabled,
logger,
),
);
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions, apiKeys));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// API-key management — interactive sessions only (guests excluded; the
// router itself rejects key-authenticated requests).
app.use("/api/keys", requireNotGuest, createApiKeysRouter(apiKeys, audit, logger));
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));
+17 -12
View File
@@ -1,19 +1,24 @@
@echo off
title TSMusicBot
:: ============================================================
:: TSMusicBot - convenience launcher at the repo root.
:: Everything real lives in scripts\start.bat; this file only makes sure we
:: run from the project directory and then delegates, so both entry points
:: behave identically (same node/dist checks, same native-module preflight).
:: ============================================================
:: Check node
where node >nul 2>&1
if errorlevel 1 (
echo Node.js not found. Run scripts\setup.bat first.
cd /d "%~dp0" || (
echo [FATAL] Cannot change to the project directory.
pause
exit /b 1
)
echo Starting TSMusicBot...
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
:: Keep lines inside parenthesised blocks pure ASCII: cmd.exe mis-tracks its
:: file offset when a block contains multi-byte UTF-8 and eats the "echo " prefix.
if not exist "scripts\start.bat" (
echo scripts\start.bat not found - is this the TSMusicBot project folder?
pause
exit /b 1
)
node dist\index.js
pause
call "scripts\start.bat"
exit /b %errorlevel%
+92
View File
@@ -0,0 +1,92 @@
const { Client, generateIdentity } = require('@honeybbq/teamspeak-client');
function escapeTS3(value) {
return value
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/ /g, "\\s")
.replace(/\|/g, "\\p")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
function replaceField(cmd, key, value) {
const escaped = escapeTS3(value);
const regex = new RegExp(key + "=\\S*");
if (regex.test(cmd)) return cmd.replace(regex, key + "=" + escaped);
return cmd;
}
// Version to test - passed via env
const VERSIONS = [
{
name: "6.0.0-beta2",
version: "6.0.0-beta2 [Build: 1737468425]",
platform: "Windows",
sign: "b5mySiqYAd4Lv5TZNflW+G5Gk8y7Woo9YnJfHRjmzhUyMdGfF1O7fSGJzmx2Hpe/PKaY2aDuKKD6lSxbLYlKCw==",
},
{
name: "3.?.? wildcard",
version: "3.?.? [Build: 5680278000]",
platform: "Windows",
sign: "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==",
},
{
name: "5.0.0-beta77",
version: "5.0.0-beta77 [Build: 1702382332]",
platform: "Windows",
sign: "Ee6DzP16MUXpdKWjiSY0NGb4thN22/Ks0hwNcaMrWoaadgkM6c5477X0IbGFWVjzTWfjFTEad5noYLUPDWSgCQ==",
},
{
name: "3.6.2 (corrected sign)",
version: "3.6.2 [Build: 1695203293]",
platform: "Windows",
sign: "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw==",
},
];
const idx = parseInt(process.env.VERSION_IDX || "0");
const V = VERSIONS[idx];
const identity = generateIdentity(8);
const client = new Client(identity, "localhost:9987", "MusicBot", {
logger: {
debug: () => {},
info: (m) => console.log("[INFO]", m),
warn: (m) => console.log("[WARN]", m),
error: (m) => console.log("[ERROR]", m),
},
});
console.log("Testing version: " + V.name + " -> " + V.version);
client.connect().then(() => {
const handler = client.handler;
const origSendPacket = handler.sendPacket.bind(handler);
handler.sendPacket = (pType, data, flags) => {
if (pType === 2) {
let str = Buffer.from(data).toString("utf-8");
if (str.startsWith("clientinit ")) {
str = replaceField(str, "client_version", V.version);
str = replaceField(str, "client_platform", V.platform);
str = replaceField(str, "client_version_sign", V.sign);
console.log("[PATCHED] version=" + V.version);
origSendPacket(pType, Buffer.from(str), flags);
return;
}
}
origSendPacket(pType, data, flags);
};
return client.waitConnected();
}).then(() => {
console.log("SUCCESS! Connected with clientId = " + client.clientID());
client.disconnect();
process.exit(0);
}).catch((err) => {
console.log("ERROR:", err && err.message || err);
process.exit(1);
});
setTimeout(() => {
console.log("TIMEOUT - version rejected");
process.exit(2);
}, 10000);
+9
View File
@@ -0,0 +1,9 @@
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
// TypeScript compiles test files into dist. Test the source once, even
// when an older build is present, rather than collecting stale copies.
exclude: [...configDefaults.exclude, "dist/**", "web/dist/**"],
},
});
+22
View File
@@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Keep deployed instances out of search-engine indexes (issue #128:
searching "TsmusicBot" surfaced strangers' WebUI URLs). Defence in depth
alongside the server's X-Robots-Tag header and /robots.txt. Applies to
the SPA shell and every in-app route (incl. /bot/<id> dedicated links),
since they all share this single index.html. -->
<meta name="robots" content="noindex, nofollow">
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
does exactly that: full Referer for our own requests, none for cross-origin
@@ -16,6 +22,22 @@
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
<meta name="referrer" content="same-origin">
<title>TSMusicBot</title>
<!-- Icons live in web/public/ so Vite copies them to the dist root, which is
what Express serves (src/index.ts STATIC_DIR). Both an .ico and an .svg
are declared: browsers that understand the vector one prefer it and stay
sharp on hi-dpi tabs, the rest fall back to the .ico. The .ico also
answers the implicit /favicon.ico request — without a real file there the
SPA catch-all hands back index.html with a 200, so the tab silently keeps
the blank-page icon (issue #142). -->
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
<!-- iOS home-screen icon; iOS masks the corners itself, so this one is a
full square. Android reads the manifest instead. -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Matches --bg-primary of the dark theme, which is what the app starts in
(stores/player.ts defaults to 'dark' and never follows the OS scheme). -->
<meta name="theme-color" content="#222222">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+26
View File
@@ -0,0 +1,26 @@
{
"name": "TSMusicBot",
"short_name": "TSMusicBot",
"start_url": "/",
"display": "standalone",
"background_color": "#222222",
"theme_color": "#222222",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+240 -11
View File
@@ -7,11 +7,23 @@
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<BilibiliPartModal />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
<div class="m-player-progress">
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
<div
ref="seekBarRef"
class="m-player-progress"
:class="{ 'no-seek': !canSeek, dragging: seeking }"
@pointerdown="onSeekDown"
@pointermove="onSeekMove"
@pointerup="onSeekUp"
@pointercancel="onSeekCancel"
>
<div class="m-player-progress-track">
<div class="m-player-progress-fill" :style="{ width: seekBarPct + '%' }" />
<div class="m-player-progress-thumb" :style="{ left: seekBarPct + '%' }" />
</div>
</div>
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
<div class="m-player-info">
@@ -79,7 +91,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js';
@@ -91,6 +103,7 @@ import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
import BilibiliPartModal from './components/BilibiliPartModal.vue';
const playerStore = usePlayerStore();
const session = useSession();
@@ -132,15 +145,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null;
function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
// While the finger owns the bar, the clock must keep its hands off it — see
// seekBarPct below. Skipping the write (rather than letting it be overridden)
// also avoids 60 pointless reactive re-renders per second mid-drag.
if (!seeking.value) {
const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
}
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
// --- Mini-player seek (#143) -------------------------------------------------
// The mobile progress bar used to be display-only. It now supports tap-to-seek
// and drag-to-seek via Pointer Events (one code path for touch, pen and mouse —
// no mouse/touch handler pairs) with setPointerCapture, so the drag survives the
// finger sliding off the 12px strip.
//
// Decoupling, exactly the reasoning of composables/useDecoupledSlider.ts (#111):
// updateMobileProgress() rewrites the rendered percentage every animation frame
// from the *server* clock, which is still the pre-seek position while the user
// drags. Binding the bar straight to it would snap the fill back under the
// finger ~60 times a second. So the rendered value is a computed that switches
// its source: the finger while `seeking`, the clock otherwise.
const seekBarRef = ref<HTMLElement | null>(null);
const seeking = ref(false);
const seekPct = ref(0);
let seekPointerId: number | null = null;
// The song the gesture started on. currentSong can advance mid-drag (the track
// ends), and the ratio the finger picked means nothing against a different
// song's duration.
let seekSongId: string | null = null;
// Bumped per gesture so a slow seek POST can't clear the override belonging to a
// newer drag that started while it was still in flight.
let seekGeneration = 0;
// Timestamp of the last seek gesture end, used to swallow the trailing click
// (see onPlayerRowClick).
let seekEndedAt = 0;
const seekBarPct = computed(() => (seeking.value ? seekPct.value : mobileProgressPct.value));
/** Pointer x → 0..1 along the strip, or null when the element isn't measurable. */
function seekRatio(e: PointerEvent): number | null {
const el = seekBarRef.value;
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return null;
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
}
/** Duration guard: live streams report 0/undefined and ratio*0 would seek to 0,
* while a missing duration would produce NaN — which the API rejects with 400. */
function seekableDuration(): number {
const duration = currentSong.value?.duration ?? 0;
return Number.isFinite(duration) && duration > 0 ? duration : 0;
}
// Drives the `no-seek` class as well as the gesture guard, so a bar that cannot
// be seeked also gives `touch-action` back to the page — otherwise the strip
// would be a 12px band that neither seeks nor scrolls.
const canSeek = computed(() => canTransport.value && seekableDuration() > 0);
function endSeekGesture() {
const el = seekBarRef.value;
if (el && seekPointerId !== null && el.hasPointerCapture?.(seekPointerId)) {
el.releasePointerCapture(seekPointerId);
}
seekPointerId = null;
seekEndedAt = Date.now();
}
function onSeekDown(e: PointerEvent) {
// Seeking is gated on transport, like the desktop player's `no-seek` state:
// without it the bar stays purely visual and taps fall through to the row.
if (!canSeek.value) return;
// One gesture at a time: a second finger landing on the strip would otherwise
// steal seekPointerId, leaving the first pointer captured forever and
// committing whichever finger happened to lift first.
if (seekPointerId !== null) return;
const ratio = seekRatio(e);
if (ratio === null) return;
// Never let the row's router.push('/lyrics') fire while the user is seeking.
e.stopPropagation();
e.preventDefault(); // suppress text selection / compat mouse events during the drag
seekPointerId = e.pointerId;
seekSongId = currentSong.value?.id ?? null;
seekGeneration += 1;
seekBarRef.value?.setPointerCapture?.(e.pointerId);
seeking.value = true;
seekPct.value = ratio * 100;
}
function onSeekMove(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
const ratio = seekRatio(e);
if (ratio === null) return;
e.stopPropagation();
seekPct.value = ratio * 100;
}
async function onSeekUp(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
e.stopPropagation();
// A tap never moves, so pointerup is also the commit point for tap-to-seek.
const ratio = seekRatio(e) ?? seekPct.value / 100;
seekPct.value = ratio * 100;
const duration = seekableDuration();
const generation = seekGeneration;
// If the track advanced while the finger was down, the ratio belongs to a
// song that is no longer playing — drop the seek rather than applying it to
// whatever started next.
const sameSong = currentSong.value?.id === seekSongId;
endSeekGesture(); // must run synchronously, before the awaited POST
try {
if (duration > 0 && sameSong) await playerStore.seek(ratio * duration);
} catch {
// Seek rejected (403/400/offline) — fall back to the server clock below.
} finally {
// Release the local override only once seek() has resolved. store.seek()
// moves its timing anchor to the requested position in the same tick, so
// liveElapsed() already reports the new spot and the bar simply carries on
// from where the finger left it. Releasing at pointerup instead would show
// the *old* position for one round-trip and then jump a second time.
// (_syncAfterAction re-polls 500ms later, but that only nudges the bar by
// the network delta — not worth freezing the clock for.)
if (generation === seekGeneration) seeking.value = false;
}
}
function onSeekCancel(e: PointerEvent) {
if (e.pointerId !== seekPointerId) return;
// Gesture stolen (system gesture, call, …): abandon without seeking and hand
// the bar straight back to the clock.
endSeekGesture();
seeking.value = false;
}
// The whole mini player lives inside `v-if="currentSong"`, so when playback
// stops mid-drag the strip is destroyed and no pointerup/pointercancel can ever
// reach it — element removal is not a pointercancel trigger. Without this the
// `seeking` override would stay true and the progress bar would sit frozen for
// the rest of the session. Bumping the generation also neuters the finally of
// any seek still in flight.
watch(currentSong, () => {
if (!seeking.value) return;
seekGeneration += 1;
seekPointerId = null;
seekSongId = null;
seeking.value = false;
});
function onPlayerRowClick() {
// Both a tap and a drag on the strip emit a trailing `click`, which would
// otherwise navigate to /lyrics the moment the user finishes seeking. A
// `@click.stop` on the strip is not enough: after a drag the click's target is
// the nearest common ancestor of the pointerdown/pointerup hit-tests, i.e.
// `.m-player` itself once the finger has left the 12px strip. So the row
// swallows any click arriving right after a seek gesture. A timestamp rather
// than a flag, so a gesture that produces no click at all (preventDefault,
// pointercancel) can't leave the row permanently unclickable — and so an inert
// strip (no transport permission, unknown duration) still falls through here
// and navigates, exactly as it did before.
if (Date.now() - seekEndedAt < 400) return;
router.push('/lyrics');
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
@@ -166,6 +338,9 @@ onMounted(async () => {
// Search or Playlist render hearts correctly without first visiting Home.
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
playerStore.fetchFavorites();
// Non-critical: reads savedQueuesEnabled so the nav entry can show/hide.
// Guests get a 403 (swallowed) → the entry stays hidden for them.
if (!session.isGuest.value) playerStore.fetchBotSettings();
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
// Reconcile the dedicated-link scope only after the bot list is known: the
@@ -239,15 +414,69 @@ onUnmounted(() => {
top: 0;
left: 10px;
right: 10px;
// The visible track stays 2px, but 2px is not a touch target (#143), so the
// hit area is 12px and grows DOWNWARD into the mini player's own 8px top
// padding. It must not reach the transport buttons: they are 32px tall and
// centred in the 58px row's 42px content box, i.e. their top edge sits at
// 8 + (42 - 32) / 2 = 13px. 12px clears them by 1px. Growing upward is not an
// option — that is outside the player's rounded top edge.
height: 12px;
// Without this the browser claims the gesture for page scrolling partway
// through the drag and the pointermove stream stops.
touch-action: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
// No transport permission → purely decorative (mirrors Player.vue's .no-seek).
// Handing touch-action back matters: an inert strip must not eat gestures.
&.no-seek {
touch-action: auto;
}
}
.m-player-progress-track {
position: relative;
// Nudged down inside the 12px hit area so the drag thumb, which is centred on
// the track, stays within the card instead of poking out above its top edge.
// The 8px thumb's box is (3 + 1 - 4) = 0 to 8, i.e. exactly flush with the
// card. (The container is absolutely positioned, so it forms a BFC and this
// margin cannot collapse through it.)
margin-top: 3px;
height: 2px;
border-radius: 1px;
background: var(--border-color);
}
.m-player-progress-fill {
height: 2px;
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary);
border-radius: 1px;
}
.m-player-progress-thumb {
position: absolute;
top: 1px;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
background: var(--color-primary);
border-radius: var(--radius-full);
opacity: 0;
transform: scale(0);
transition: opacity var(--transition-fast), transform var(--transition-fast);
pointer-events: none;
}
.m-player-progress.dragging .m-player-progress-thumb {
opacity: 1;
transform: scale(1);
}
.m-player-info {
flex: 1;
min-width: 0;
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more