Compare commits

...
32 Commits
Author SHA1 Message Date
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
63 changed files with 7212 additions and 199 deletions

No files matched your search

+133 -9
View File
@@ -42,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站热门视频和个性化推荐(登录后更准确)
@@ -65,22 +65,22 @@
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
```
1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
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
```
> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
> **先装 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 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.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 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
**前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
FFmpeg **已自动内置**,无需手动安装。
```bash
@@ -136,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
```
## 更新升级
@@ -364,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(默认网易云,自动续播) |
@@ -440,6 +462,52 @@ sudo systemctl start tsmusicbot
聊天命令、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`。
## 项目架构
```
@@ -495,6 +563,7 @@ teamspeak-music-bot/
├── scripts/ # 部署脚本
│ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
@@ -510,7 +579,7 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 |
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
@@ -884,7 +953,62 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
### 最新版本 — 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。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
+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 次的限流。
@@ -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.
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.12.0 || >=24.0.0"
},
"scripts": {
"dev": "tsx watch src/index.ts",
+6 -1
View File
@@ -23,6 +23,7 @@ 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");
@@ -106,7 +107,11 @@ for (const spec of REQUIRED) {
function report() {
const stamp = readStamp();
const mismatch = broken.find((b) => b.abiMismatch);
const out = (line) => process.stderr.write(`${line}\n`);
// 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("============================================================");
+4 -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).
@@ -51,6 +51,8 @@ COPY --from=builder /app/node_modules ./node_modules
# 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
+39 -6
View File
@@ -51,6 +51,7 @@ import { Readable } from "node:stream";
import { execFileSync, execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createLineWriter } from "./lib/console-log.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules");
@@ -61,6 +62,11 @@ const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
/** The newest Node major this project is regularly tested against, and the one
* every required addon currently ships a prebuild for. Keep in sync with
* TESTED_NODE_MAJOR in scripts/setup.bat. */
const TESTED_NODE_MAJOR = 22;
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
@@ -79,10 +85,17 @@ const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
// 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}`;
process.stderr.write(`${line}\n`);
if (ECHO_STDOUT) process.stdout.write(`${line}\n`);
writeErr(line);
if (ECHO_STDOUT) writeOut(line);
}
// ---------------------------------------------------------------------------
@@ -401,6 +414,25 @@ function buildFromSource(command) {
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)");
@@ -521,6 +553,7 @@ async function ensureOpus() {
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}`);
}
@@ -591,6 +624,7 @@ async function ensureBetterSqlite3() {
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`);
}
@@ -661,10 +695,9 @@ try {
// that same loop. Run these concurrently and the first module to fall back to
// a source build kills every download still in flight — the connection is
// healthy, the timer just never got a chance to be reset. That is not a rare
// race: npmmirror has no opus prebuild for ABI 137 (Node 24) and no
// better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node
// versions this project supports, one module 404s within ~100ms and starts
// building while ffmpeg's ~80MB download is still going. ffmpeg is optional,
// 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.
+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);
});
});
+11 -9
View File
@@ -11,7 +11,7 @@ title TSMusicBot Setup
:: ============================================================
set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=20"
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"
@@ -67,13 +67,15 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
:: The supported floor is not just a major version, so let node decide:
:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs
:: >=20.17 / >=22.9, and the odd majors (21 / 23) are excluded by
:: better-sqlite3 and vitest. Keep this in sync with "engines" in package.json.
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
:: 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 20.19+ LTS or Node 22.12+ LTS."
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
@@ -86,10 +88,10 @@ if errorlevel 1 (
:: characters and starts eating the "echo " prefix of following lines.
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22.
echo [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 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
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%"
)
+9 -7
View File
@@ -21,7 +21,7 @@ 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
@@ -32,16 +32,18 @@ echo "[OK] Node.js $(node -v)"
TESTED_NODE_MAJOR=22
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client
# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors
# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines".
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
# 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 20 / Node 22)."
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."
+8
View File
@@ -64,6 +64,14 @@ describe("buildFfmpegArgs", () => {
expect(ssIdx).toBeGreaterThan(iIdx);
});
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
const ssIdx = args.indexOf("-ss");
expect(args[ssIdx + 1]).toBe("3600");
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
});
it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss");
+11 -2
View File
@@ -76,8 +76,9 @@ export function cleanupTempDir(dir: string): void {
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
if (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,9 +104,15 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
// to the byte offset. Output-side seek would download and decode everything
// before the target first — minutes for a resume deep into a 3-hour video
// (#161), long enough to trip the stall watchdog.
const inputSideSeek = isBilibili;
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
@@ -827,6 +834,8 @@ export class AudioPlayer extends EventEmitter {
}
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().
+398
View File
@@ -3,6 +3,7 @@ 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";
@@ -1538,3 +1539,400 @@ describe("BotInstance live-queue persistence (#119)", () => {
}
});
});
describe("BotInstance Bilibili multi-P resolution", () => {
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
const multiPSongDetail = {
id: "BV1multiP?p=1",
name: "测试视频 - P1 分P1",
artist: "UP主",
album: "",
duration: 100, // P1 duration
coverUrl: "",
platform: "bilibili" as const,
};
const mockBili = {
platform: "bilibili" as const,
search: vi.fn().mockResolvedValue({
songs: [{
id: "BV1multiP",
name: "测试视频",
artist: "UP主",
album: "",
duration: 300, // total duration in search
coverUrl: "",
platform: "bilibili",
}],
albums: [],
playlists: [],
}),
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
};
const ctx = {
config: { commandPrefix: "!" },
lastSearchResults: [] as any[],
getProvider: () => mockBili,
getProviderFor: () => mockBili,
};
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
name: "play",
args: "测试视频",
rawArgs: ["测试视频"],
flags: new Set(),
});
expect(res.song).toBeDefined();
expect(res.song.id).toBe("BV1multiP?p=1");
expect(res.song.name).toBe("测试视频 - P1 分P1");
expect(res.song.duration).toBe(100);
});
});
describe("cmdPlaylist with a playlist link (#160)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
) => Promise<string>;
function makeCtx() {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
const makeProvider = (platform: string) => ({
platform,
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
};
const queued: any[] = [];
return {
providers,
queued,
getProvider: vi.fn(() => providers.netease),
getProviderFor: vi.fn((p: string) => providers[p]),
assertProviderEnabled: vi.fn(),
extractId: (BotInstance.prototype as any).extractId,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
player: { stop: vi.fn() },
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
disableFmMode: vi.fn(),
withRequester: (s: any) => s,
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
};
}
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
const ctx = makeCtx();
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
expect(ctx.queued[0].platform).toBe("qq");
expect(reply).toMatch(/^Loaded 1 songs/);
});
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
});
it("checks the link's platform is enabled", async () => {
const ctx = makeCtx();
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
});
it("keeps the old behavior for a bare id", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282"));
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
});
});
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([]);
});
});
+172 -11
View File
@@ -13,7 +13,13 @@ import {
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import {
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
@@ -193,6 +199,8 @@ 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. */
@@ -322,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) => {
@@ -456,11 +484,16 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number }) => {
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);
}
@@ -968,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");
@@ -1108,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);
@@ -1134,7 +1258,12 @@ 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.
@@ -1151,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> {
@@ -1320,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) =>
@@ -1332,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"));
@@ -1495,8 +1641,21 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args);
@@ -1504,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 {
+238
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[] = [];
@@ -202,3 +207,236 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
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);
});
});
+216 -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,
@@ -112,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);
}
@@ -133,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,
@@ -150,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 };
}
@@ -252,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 ---
@@ -319,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);
}
}
@@ -400,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 {
@@ -439,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.
+63 -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", () => {
@@ -120,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();
});
});
+81
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
@@ -98,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;
}
}
+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;
+33
View File
@@ -345,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();
});
});
+51
View File
@@ -144,6 +144,10 @@ export interface BotDatabase {
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[];
@@ -268,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,
@@ -328,6 +344,17 @@ function initTables(db: Database.Database): void {
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
);
`);
}
@@ -453,6 +480,17 @@ 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[] => {
@@ -634,6 +672,19 @@ 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}`);
+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;
+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[]> {
+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,
+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;
}
+179
View File
@@ -438,3 +438,182 @@ describe("music router POST /local/upload — content types and size cap (#149)"
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" },
]);
});
});
+64 -3
View File
@@ -1,5 +1,5 @@
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, saveConfig, type BotConfig } from "../../data/config.js";
@@ -198,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),
@@ -226,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 });
@@ -297,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);
@@ -382,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.
+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");
});
});
+130 -2
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,6 +496,99 @@ export function createPlayerRouter(
}
});
// 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
+66 -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;
@@ -176,6 +178,68 @@ describe("session router", () => {
}, 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);
});
});
describe("session router — guest mode", () => {
let botDb: BotDatabase;
+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, {
+18 -3
View File
@@ -19,8 +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";
@@ -32,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";
@@ -101,6 +104,7 @@ 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
@@ -127,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);
@@ -203,6 +207,13 @@ 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(
@@ -217,9 +228,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
// 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));
+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/**"],
},
});
+2
View File
@@ -7,6 +7,7 @@
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<BilibiliPartModal />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
@@ -102,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();
+293
View File
@@ -0,0 +1,293 @@
<template>
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
<div class="edit-modal">
<h3 class="modal-title">选择分P</h3>
<div class="form-group">
<label>视频名称</label>
<div class="video-info-box">
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
<div class="video-meta">
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>分P列表</label>
<div class="parts-list">
<div
v-for="part in modal.parts"
:key="part.part"
class="part-item"
:class="{ active: selectedPart?.part === part.part }"
@click="selectedPart = part"
@dblclick="confirmSelect(part)"
>
<span class="part-badge">P{{ part.part }}</span>
<span class="part-name" :title="part.title">{{ part.title }}</span>
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
<button
class="btn-primary"
:disabled="!selectedPart"
@click="selectedPart && confirmSelect(selectedPart)"
>
{{ confirmBtnText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { usePlayerStore, type BiliPart } from '../stores/player.js';
import CoverArt from './CoverArt.vue';
const store = usePlayerStore();
const modal = computed(() => store.biliPartModal);
const selectedPart = ref<BiliPart | null>(null);
// 弹窗打开时默认选中第 1 P
watch(
() => modal.value.open,
(open) => {
if (open && modal.value.parts.length > 0) {
selectedPart.value = modal.value.parts[0];
} else {
selectedPart.value = null;
}
},
{ immediate: true },
);
const actionHint = computed(() => {
if (modal.value.action === 'playNext') return '添加到下一首播放';
if (modal.value.action === 'add') return '添加到播放队列';
return '立即播放';
});
const confirmBtnText = computed(() => {
if (modal.value.action === 'playNext') return '下一首播放';
if (modal.value.action === 'add') return '添加到队列';
return '播放';
});
function confirmSelect(part: BiliPart) {
store.selectBilibiliPart(part);
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '--:--';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<style lang="scss" scoped>
.edit-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.edit-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 28px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
min-height: 0;
label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
opacity: 0.7;
color: var(--text-primary);
}
}
.video-info-box {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
.video-meta {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.video-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.parts-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-sm);
}
}
.part-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
.part-badge {
background: var(--color-primary);
color: white;
}
.part-name {
color: var(--color-primary);
font-weight: 600;
}
}
.part-badge {
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: var(--radius-xs);
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
color: var(--color-primary);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.part-name {
flex: 1;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.part-duration {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
}
.btn-primary {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
border: none;
cursor: pointer;
transition: transform var(--transition-fast);
&:hover:not(:disabled) {
transform: scale(1.02);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-secondary {
padding: 10px 20px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
border: none;
cursor: pointer;
transition: opacity var(--transition-fast);
&:hover {
opacity: 0.8;
}
}
</style>
@@ -0,0 +1,261 @@
<template>
<!-- The signed-in user's own NetEase account, used for THEIR 私人FM instead
of the bot's shared login (#164). -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:radio" class="account-icon" />
<div class="account-info">
<div class="account-name">我的网易云账号(私人FM)</div>
<div class="account-status" :class="{ logged: status.loggedIn }">
<template v-if="status.loggedIn">已绑定: {{ status.nickname }}</template>
<template v-else-if="status.linked">已绑定,但登录已失效,请重新扫码</template>
<template v-else>未绑定 — 私人FM使用机器人的共享账号</template>
</div>
</div>
</div>
<p class="hint">
绑定后,你在网页端开启的网易云私人FM会按你自己的口味推荐;其他人不受影响。
仅保存在服务器上,不会显示给任何人。
</p>
<div class="login-methods">
<button class="login-btn" :disabled="qr.loading" @click="startQrLogin">
<Icon icon="mdi:qrcode" />
{{ status.linked ? '重新扫码绑定' : '扫码绑定' }}
</button>
<button v-if="status.linked" class="login-btn" @click="unlink">
<Icon icon="mdi:link-off" />
解除绑定
</button>
</div>
<div v-if="qr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="qr.dataUrl" class="qr-wrap">
<img :src="qr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="qr.status">
<template v-if="qr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用网易云音乐APP扫码
</template>
<template v-else-if="qr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="qr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 绑定成功!
</template>
<template v-else-if="qr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin">重新生成</button>
</template>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import QRCode from 'qrcode';
const BASE = '/api/me/music/netease';
const status = reactive({ linked: false, loggedIn: false, nickname: '' });
const qr = reactive({
loading: false,
dataUrl: '',
key: '',
status: 'waiting' as 'waiting' | 'scanned' | 'confirmed' | 'expired',
});
const error = ref('');
let pollTimer: ReturnType<typeof setInterval> | null = null;
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
}
async function refreshStatus() {
try {
const res = await axios.get(`${BASE}/status`);
status.linked = Boolean(res.data?.linked);
status.loggedIn = Boolean(res.data?.loggedIn);
status.nickname = res.data?.nickname ?? '';
} catch {
// Leave the last known state
}
}
async function startQrLogin() {
stopPolling();
error.value = '';
qr.loading = true;
qr.dataUrl = '';
qr.status = 'waiting';
try {
const res = await axios.post(`${BASE}/qrcode`);
const { qrUrl, qrImg, key } = res.data;
qr.key = key;
// Dark-on-light only: many in-app scanners can't read an inverted code.
qr.dataUrl = qrImg || (await QRCode.toDataURL(qrUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}));
pollTimer = setInterval(pollQrStatus, 2000);
} catch (err: any) {
error.value = err?.response?.data?.error ?? '二维码生成失败';
} finally {
qr.loading = false;
}
}
async function pollQrStatus() {
if (!qr.key) return;
try {
const res = await axios.get(`${BASE}/qrcode/status`, { params: { key: qr.key } });
qr.status = res.data.status;
if (qr.status === 'confirmed') {
stopPolling();
await refreshStatus();
} else if (qr.status === 'expired') {
stopPolling();
}
} catch {
// Ignore poll errors
}
}
async function unlink() {
error.value = '';
try {
await axios.delete(BASE);
stopPolling();
qr.dataUrl = '';
await refreshStatus();
} catch (err: any) {
error.value = err?.response?.data?.error ?? '解除绑定失败';
}
}
onMounted(refreshStatus);
onUnmounted(stopPolling);
</script>
<style lang="scss" scoped>
.account-card {
margin-top: 16px;
padding: 20px;
background: var(--hover-bg);
border-radius: var(--radius-md);
}
.account-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.account-icon {
font-size: 28px;
color: var(--color-primary);
}
.account-name {
font-weight: 600;
}
.account-status {
font-size: 12px;
color: var(--text-tertiary);
&.logged { color: var(--color-online); }
}
.hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 12px;
line-height: 1.5;
}
.login-methods {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.login-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: inherit;
cursor: pointer;
transition: all var(--transition-fast);
&:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
&:disabled { opacity: 0.6; cursor: default; }
}
.qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.qr-image {
width: 200px;
height: 200px;
border-radius: var(--radius-md);
border: 2px solid var(--border-color);
}
.qr-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
&.confirmed { color: var(--color-online); }
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
}
.error {
margin-top: 8px;
font-size: 12px;
color: #e26a6a;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+1
View File
@@ -20,6 +20,7 @@ const router = createRouter({
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
{ path: '/artist/:id', name: 'artist', component: () => import('../views/Artist.vue') },
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
+138 -3
View File
@@ -17,6 +17,24 @@ export interface Song {
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
export interface BiliPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliPartModalState {
open: boolean;
song: Song | null;
action: 'play' | 'playNext' | 'add';
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliPart[];
}
export interface AlbumItem {
id: string;
name: string;
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
// Bilibili 多P分P选择弹窗状态
biliPartModal: {
open: false,
song: null,
action: 'play',
bvid: '',
title: '',
coverUrl: '',
artist: '',
parts: [] as BiliPart[],
} as BiliPartModalState,
}),
getters: {
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
/**
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
*/
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
try {
const cleanBvid = song.id.split('?')[0].split(':')[0];
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
const parts: BiliPart[] = res.data?.parts ?? [];
if (parts.length > 1) {
this.biliPartModal = {
open: true,
song,
action,
bvid: cleanBvid,
title: res.data.title || song.name,
coverUrl: res.data.coverUrl || song.coverUrl,
artist: res.data.artist || song.artist,
parts,
};
return true; // 弹窗接管
}
if (parts.length === 1) {
song.duration = parts[0].duration;
}
} catch {
// 网络请求异常则降级为正常播放
}
return false;
},
selectBilibiliPart(part: BiliPart) {
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
this.biliPartModal.open = false;
const partTitle = part.title && part.title !== title
? `${title} - P${part.part} ${part.title}`
: `${title} (P${part.part})`;
const partSong: Song = {
...song,
id: `${bvid}?p=${part.part}`,
name: partTitle,
artist: artist || song.artist,
coverUrl: coverUrl || song.coverUrl,
duration: part.duration,
};
if (action === 'play') {
this.playSong(partSong, true);
} else if (action === 'playNext') {
this.playNextSong(partSong, true);
} else if (action === 'add') {
this.addSong(partSong, true);
}
},
closeBilibiliPartModal() {
this.biliPartModal.open = false;
this.biliPartModal.song = null;
},
async playSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'play');
if (handled) return;
}
// Guests use the non-destructive "play now" (insert-next + skip) so they
// can't wipe everyone else's queue; members/admins keep the normal behavior.
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
async playNextSong(song: Song) {
async playNextSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
if (handled) return;
}
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
async addSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'add');
if (handled) return;
}
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},
@@ -479,6 +583,37 @@ export const usePlayerStore = defineStore('player', {
}
},
/**
* Queue an artist's songs. The server always loads the singer's FULL
* catalogue (never just the hot 50), which costs it a few upstream round
* trips, so the caller gets a "loading" notice first.
*/
async playArtist(artistId: string, platform = 'netease') {
if (!this.activeBotId) return;
this.notify('正在载入该歌手的全部歌曲…', 'info');
try {
const res = await axios.post(
`/api/player/${this.activeBotId}/play-artist`,
{ artistId, platform },
);
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
} catch (e: any) {
const status = e?.response?.status;
this.notify(
status === 403
? '没有权限播放整个歌手'
: status === 501
? '该音源不支持播放歌手歌曲'
: '播放歌手失败',
'error',
);
}
},
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at the current LIVE interpolated value. Using the cached
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
const currentDir = dirname(fileURLToPath(import.meta.url));
const source = () => readFileSync(join(currentDir, "Artist.vue"), "utf8");
describe("Artist page layout", () => {
it("has no 全部歌曲 browser section (play buttons queue the catalogue instead)", () => {
const src = source();
expect(src).not.toContain("全部歌曲</h2>");
expect(src).not.toContain("加载更多");
expect(src).not.toContain("加载全部歌曲");
expect(src).not.toContain("ALL_SONGS_PAGE");
expect(src).not.toContain("loadMoreSongs");
expect(src).not.toContain("allSongs");
});
it("keeps the hot songs and album sections", () => {
const src = source();
expect(src).toContain("热门歌曲");
expect(src).toContain("专辑");
expect(src).toContain("显示全部");
});
it("plays through playArtist without an all flag (the server always loads everything)", () => {
const src = source();
expect(src).toContain("store.playArtist(artistId(), platform.value)");
expect(src).not.toContain("playArtist(artistId(), platform.value, true)");
});
it("refetches when navigating between artists", () => {
const src = source();
expect(src).toContain("watch(");
expect(src).toContain("route.params.id");
});
});
+421
View File
@@ -0,0 +1,421 @@
<template>
<div class="artist-page">
<button class="back-btn" @click="$router.back()">
<Icon icon="mdi:arrow-left" />
返回
</button>
<div v-if="loading" class="loading">加载中...</div>
<template v-else-if="artist">
<!-- Hero: Apple Music style — round portrait, then name / meta / actions. -->
<div class="artist-hero">
<div class="artist-portrait-wrap">
<img
v-if="artist.avatarUrl"
class="artist-portrait"
:src="artist.avatarUrl"
:alt="artist.name"
referrerpolicy="no-referrer"
/>
<div v-else class="artist-portrait artist-portrait-fallback">
<Icon icon="mdi:account-music" />
</div>
</div>
<div class="artist-meta">
<div class="artist-platform">{{ platformLabel }}</div>
<h1 class="artist-title">{{ artist.name }}</h1>
<div v-if="artist.aliases?.length" class="artist-aliases">
{{ artist.aliases.join(' / ') }}
</div>
<div class="artist-stats">
<span v-if="hotSongs.length">热门歌曲 {{ hotSongs.length }} 首</span>
<span v-if="albums.length">专辑 {{ albums.length }} 张</span>
<span v-if="artist.songCount">共 {{ artist.songCount }} 首歌</span>
</div>
<p v-if="artist.description" class="artist-desc">{{ artist.description }}</p>
<div class="artist-actions">
<button
v-if="canPlayAll"
class="play-all-btn"
:disabled="!artist"
title="播放该歌手的全部歌曲"
@click="playAll"
>
<Icon icon="mdi:play" />
播放
</button>
<button
v-if="canShuffle"
class="shuffle-btn"
:disabled="!artist"
title="随机播放该歌手的全部歌曲"
@click="shuffleAll"
>
<Icon icon="mdi:shuffle" />
随机播放
</button>
</div>
</div>
</div>
<section v-if="hotSongs.length" class="artist-section">
<h2 class="section-title">热门歌曲</h2>
<div class="song-list">
<SongCard
v-for="(song, i) in visibleSongs"
:key="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>
<button v-if="hotSongs.length > visibleCount" class="more-btn" @click="expanded = true">
显示全部 {{ hotSongs.length }} 首
</button>
</section>
<section v-if="albums.length" class="artist-section">
<h2 class="section-title">专辑</h2>
<div class="album-row">
<router-link
v-for="al in albums"
:key="al.id"
:to="`/album/${al.id}?platform=${al.platform}`"
class="album-card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="150" :radius="10" :show-shadow="true" />
<div class="album-name">{{ al.name }}</div>
<div v-if="al.songCount" class="album-sub">{{ al.songCount }} 首</div>
</router-link>
</div>
</section>
</template>
<div v-else class="loading">歌手不存在或加载失败</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue';
interface ArtistDetail {
id: string;
name: string;
avatarUrl: string;
aliases?: string[];
songCount?: number;
albumCount?: number;
platform: string;
description?: string;
}
interface Album {
id: string;
name: string;
artist: string;
coverUrl: string;
songCount?: number;
platform: string;
}
/** Hot songs shown before the "显示全部" expander (the API returns up to 50). */
const HOT_SONG_PREVIEW = 10;
const store = usePlayerStore();
const route = useRoute();
const { can, guestCan } = useSession();
// Same permission as "play all" on a playlist: members need player.control,
// guests need the playCollection flag.
const canPlayAll = computed(() => can('player.control') || guestCan('playCollection'));
const canShuffle = computed(() =>
canPlayAll.value && (can('player.control') || guestCan('playMode')),
);
const artist = ref<ArtistDetail | null>(null);
const hotSongs = ref<Song[]>([]);
const albums = ref<Album[]>([]);
const loading = ref(true);
const expanded = ref(false);
let artistRequest = 0;
const platform = computed(() => (route.query.platform as string) || 'netease');
const platformLabel = computed(() => (platform.value === 'qq' ? 'QQ 音乐' : '网易云音乐'));
const visibleCount = computed(() =>
expanded.value ? hotSongs.value.length : Math.min(HOT_SONG_PREVIEW, hotSongs.value.length),
);
const visibleSongs = computed(() => hotSongs.value.slice(0, visibleCount.value));
function artistId(): string {
return route.params.id as string;
}
async function playAll() {
// The server queues the singer's whole catalogue, not just the hot 50.
await store.playArtist(artistId(), platform.value);
}
async function shuffleAll() {
if (!canShuffle.value) return;
// Shuffle reuses the queue's own random mode (the same switch the player
// toolbar exposes), so the play button and the mode badge stay consistent.
try {
await store.setMode('random');
await store.playArtist(artistId(), platform.value);
} catch (err: any) {
store.notify(err?.response?.status === 403 ? '没有权限切换随机播放' : '切换随机播放失败', 'error');
}
}
async function loadArtist() {
const request = ++artistRequest;
loading.value = true;
// Reset every per-artist piece: RouterView reuses this component when only the
// route params change, so artist → artist navigation must not show stale rows.
artist.value = null;
hotSongs.value = [];
albums.value = [];
expanded.value = false;
try {
const res = await axios.get(`/api/music/artist/${artistId()}`, {
params: { platform: platform.value },
});
if (request !== artistRequest) return;
artist.value = res.data?.artist ?? null;
hotSongs.value = res.data?.songs ?? [];
albums.value = res.data?.albums ?? [];
} catch {
if (request !== artistRequest) return;
artist.value = null;
} finally {
if (request === artistRequest) loading.value = false;
}
}
onMounted(loadArtist);
watch(() => `${route.params.id}|${route.query.platform ?? ''}`, loadArtist);
</script>
<style lang="scss" scoped>
.back-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
opacity: 0.7;
margin-bottom: 16px;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
.artist-hero {
display: flex;
align-items: center;
gap: 32px;
margin-bottom: 36px;
}
.artist-portrait-wrap {
flex-shrink: 0;
}
.artist-portrait {
width: 180px;
height: 180px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.artist-portrait-fallback {
display: flex;
align-items: center;
justify-content: center;
font-size: 72px;
color: var(--text-secondary);
background: var(--bg-card);
}
.artist-meta {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.artist-platform {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: 6px;
}
.artist-title {
font-size: 32px;
font-weight: 800;
margin-bottom: 6px;
}
.artist-aliases {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.artist-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.artist-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
max-width: 640px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist-actions {
display: flex;
align-items: center;
gap: 12px;
}
.play-all-btn,
.shuffle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 24px;
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 600;
transition: transform var(--transition-fast), background var(--transition-fast);
&:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
}
.play-all-btn {
background: var(--color-primary);
color: white;
&:not(:disabled):hover { transform: scale(1.04); }
&:not(:disabled):active { transform: scale(0.96); }
}
.shuffle-btn {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
&:not(:disabled):hover {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-8);
}
}
.artist-section {
margin-bottom: 32px;
}
.section-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 14px;
}
.song-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.more-btn {
margin-top: 10px;
padding: 8px 18px;
font-size: 13px;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: color var(--transition-fast), border-color var(--transition-fast);
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
.album-row {
display: flex;
gap: 18px;
overflow-x: auto;
padding-bottom: 8px;
}
.album-card {
flex: 0 0 auto;
width: 150px;
}
.album-name {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.album-sub {
font-size: 12px;
color: var(--text-tertiary);
}
.loading {
text-align: center;
padding: 60px;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.artist-hero {
flex-direction: column;
align-items: flex-start;
gap: 18px;
}
.artist-portrait {
width: 128px;
height: 128px;
}
.artist-title {
font-size: 24px;
}
}
</style>
+302 -3
View File
@@ -12,9 +12,35 @@
v-model="query"
class="search-input"
placeholder="搜索歌曲、歌手、专辑..."
@focus="historyOpen = true"
@blur="historyOpen = false"
@keyup.enter="doSearch"
autofocus
/>
<button v-if="query" class="search-clear" @click="query = ''">
<Icon icon="mdi:close-circle" />
</button>
<!-- Recent searches for this browser. mousedown.prevent keeps the input
focused so blur doesn't close the list before the click lands. -->
<div v-if="historyOpen && historyEntries.length" class="history-dropdown">
<div class="history-dropdown-head">
<span>搜索历史</span>
<button class="history-clear-btn" @mousedown.prevent @click="clearAllHistory">清空</button>
</div>
<div
v-for="h in historyEntries"
:key="`${h.platform}:${h.q}`"
class="history-dropdown-item"
@mousedown.prevent="applyHistory(h)"
>
<Icon icon="mdi:history" class="history-item-icon" />
<span class="history-item-query">{{ h.q }}</span>
<span class="platform-badge" :class="badgeClass(h.platform)">{{ badgeLabel(h.platform) }}</span>
<button class="history-item-remove" @mousedown.prevent.stop="removeHistoryEntry(h)">
<Icon icon="mdi:close" />
</button>
</div>
</div>
</div>
<div
@@ -55,7 +81,7 @@
<div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length || allArtists.length">
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
(opt-in) comes first when enabled. -->
<div class="source-bar">
@@ -97,6 +123,31 @@
>本地</button>
</div>
<!-- Artist row: inline above the tabs, Apple Music style. Only netease/qq
model artists, so the other sources have nothing to show here. -->
<section v-if="filteredArtists.length" class="artist-row">
<router-link
v-for="ar in filteredArtists"
:key="`${ar.platform}-${ar.id}`"
:to="`/artist/${ar.id}?platform=${ar.platform}`"
class="artist-card"
>
<img
v-if="ar.avatarUrl"
class="artist-avatar"
:src="ar.avatarUrl"
:alt="ar.name"
loading="lazy"
referrerpolicy="no-referrer"
/>
<div v-else class="artist-avatar artist-avatar-fallback">
<Icon icon="mdi:account-music" />
</div>
<div class="artist-card-name">{{ ar.name }}</div>
<div v-if="ar.songCount" class="artist-card-sub">{{ ar.songCount }} 首歌</div>
</router-link>
</section>
<div class="tab-bar">
<button
class="tab"
@@ -186,7 +237,25 @@
</div>
</template>
<div v-else-if="searched" class="empty">未找到相关结果</div>
<div v-else-if="!searched" class="search-intro">
<template v-if="historyEntries.length">
<div class="history-tags-title">最近搜索</div>
<div class="history-tags">
<button
v-for="h in historyEntries"
:key="`tag-${h.platform}:${h.q}`"
class="history-tag"
@click="applyHistory(h)"
>
<Icon icon="mdi:history" />
{{ h.q }}
</button>
</div>
</template>
<div v-else class="search-intro-text">搜索歌曲、歌手或专辑,歌手支持专属页面</div>
</div>
<div v-else class="empty">未找到相关结果</div>
</div>
</template>
@@ -200,6 +269,14 @@ import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue';
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
import {
clearStoredHistory,
loadHistory,
pushHistory,
removeHistory,
saveHistory,
type SearchHistoryEntry,
} from './searchHistory.js';
const PAGE_SIZE = 20;
@@ -229,11 +306,17 @@ const selectedSource = ref<SearchSource>(loadSource());
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; }
interface Artist { id: string; name: string; avatarUrl: string; platform: string; songCount?: number; albumCount?: number; }
const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
const allArtists = ref<Artist[]>([]);
// Search history lives in localStorage (see searchHistory.ts) — loaded on mount
// so SSR/blocked-storage environments simply start empty.
const historyEntries = ref<SearchHistoryEntry[]>([]);
const historyOpen = ref(false);
const hasMoreMap = ref<Record<string, boolean>>({});
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
const loading = ref(false);
@@ -257,6 +340,10 @@ const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
const filteredArtists = computed(() =>
allArtists.value.filter((a) => a.platform === selectedSource.value)
);
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// Server-side source gate (enabledProviders). Until /providers loads, the
@@ -383,6 +470,7 @@ async function toggleFavPlaylist(pl: { id: string; platform: string; name: strin
async function doSearch() {
if (!query.value.trim()) return;
recordHistory();
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
@@ -393,16 +481,43 @@ async function doSearch() {
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
allArtists.value = res.data.artists ?? [];
recordInitialHasMore(allSongs.value, 'songs');
recordInitialHasMore(allAlbums.value, 'albums');
recordInitialHasMore(allPlaylists.value, 'playlists');
} catch {
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allArtists.value = [];
} finally {
loading.value = false;
}
}
// ---- 搜索历史 ----
function recordHistory() {
historyEntries.value = pushHistory(historyEntries.value, query.value, selectedSource.value);
saveHistory(historyEntries.value);
}
/** Re-run a stored search, restoring the source it was made from. */
function applyHistory(entry: SearchHistoryEntry) {
historyOpen.value = false;
query.value = entry.q;
if (SEARCH_SOURCES.includes(entry.platform as SearchSource) && sourceEnabled(entry.platform)) {
selectedSource.value = entry.platform as SearchSource;
}
doSearch();
}
function removeHistoryEntry(entry: SearchHistoryEntry) {
historyEntries.value = removeHistory(historyEntries.value, entry.q, entry.platform);
saveHistory(historyEntries.value);
}
function clearAllHistory() {
historyEntries.value = [];
clearStoredHistory();
}
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
const UPLOAD_MAX_MB = 500;
@@ -546,6 +661,7 @@ async function loadLocalAudioSetting() {
onMounted(async () => {
loadLocalAudioSetting();
historyEntries.value = loadHistory();
if (query.value) doSearch();
await store.fetchProviders();
fixupSelectedSource();
@@ -649,6 +765,7 @@ onMounted(async () => {
}
.search-input-wrap {
position: relative;
display: flex;
align-items: center;
padding: 14px 20px;
@@ -657,6 +774,188 @@ onMounted(async () => {
margin-bottom: 16px;
}
.search-clear {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 18px;
color: var(--text-tertiary);
cursor: pointer;
transition: color var(--transition-fast);
&:hover { color: var(--text-primary); }
}
.history-dropdown {
position: absolute;
top: calc(100% - 10px);
left: 0;
right: 0;
z-index: 20;
padding: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
}
.history-dropdown-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.history-clear-btn {
font-size: 12px;
font-family: inherit;
color: var(--text-secondary);
cursor: pointer;
&:hover { color: var(--color-primary); }
}
.history-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
&:hover { background: var(--bg-card); }
}
.history-item-icon {
flex-shrink: 0;
font-size: 16px;
color: var(--text-tertiary);
}
.history-item-query {
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-remove {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 14px;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
.history-dropdown-item:hover & { opacity: 1; }
&:hover { color: #e74c3c; }
}
.search-intro {
padding: 24px 0;
}
.history-tags-title {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.history-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border-color);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
.search-intro-text {
font-size: 14px;
color: var(--text-tertiary);
}
.artist-row {
display: flex;
gap: 18px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 16px;
}
.artist-card {
flex: 0 0 auto;
width: 104px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: inherit;
}
.artist-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
background: var(--bg-secondary);
transition: transform var(--transition-fast);
}
.artist-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
color: var(--text-tertiary);
}
.artist-card:hover .artist-avatar {
transform: scale(1.05);
}
.artist-card-name {
margin-top: 8px;
max-width: 100%;
font-size: 13px;
font-weight: var(--fw-semi);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-card-sub {
font-size: 11px;
color: var(--text-tertiary);
}
.search-icon {
font-size: 22px;
opacity: 0.4;
+156 -1
View File
@@ -48,6 +48,7 @@
</form>
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
</section>
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
@@ -1134,6 +1135,60 @@
</div>
</section>
<!-- API Keys -->
<section class="settings-section">
<h2 class="section-title">API 密钥</h2>
<p class="apikey-hint">
通过 API Key 调用本机的 REST API(请求头 <code>Authorization: Bearer</code> 或 <code>X-API-Key</code>)。
权限与你的账户一致;明文只在生成时显示一次,之后仅能看到前缀。
</p>
<div class="user-list">
<div v-for="k in apiKeyList" :key="k.id" class="user-item">
<div class="user-info">
<div class="user-name">{{ k.name }}</div>
<div class="apikey-meta">
<code class="apikey-prefix">{{ k.keyPrefix }}…</code>
<span>创建于 {{ formatDate(k.createdAt) }}</span>
<span>{{ k.lastUsedAt ? `最后使用 ${formatDate(k.lastUsedAt)}` : '从未使用' }}</span>
</div>
</div>
<div class="user-actions">
<button class="btn-sm btn-delete" title="吊销此密钥" @click="onRevokeApiKey(k)">
<Icon icon="mdi:delete" />
</button>
</div>
</div>
<div v-if="apiKeyList.length === 0 && !apiKeyLoadError" class="user-empty">还没有 API Key。</div>
<div v-if="apiKeyLoadError" class="user-error">{{ apiKeyLoadError }}</div>
</div>
<form class="user-add-form" @submit.prevent="onCreateApiKey">
<input v-model="newApiKeyName" class="input" placeholder="密钥名称(如:home-assistant)" maxlength="64" required />
<button class="btn-sm btn-primary" type="submit" :disabled="creatingApiKey">
{{ creatingApiKey ? '生成中…' : '生成密钥' }}
</button>
</form>
<p v-if="apiKeyMutationError" class="user-error">{{ apiKeyMutationError }}</p>
<!-- Created key modal: plaintext shown exactly once -->
<div v-if="createdKey" class="edit-modal-overlay" @click.self="createdKey = null">
<div class="edit-modal">
<h3 class="modal-title">密钥「{{ createdKey.key.name }}」已生成</h3>
<p class="modal-hint">请立即复制保存——这串明文只显示这一次,关闭后只能看到前缀。</p>
<div class="apikey-raw-row">
<code class="apikey-raw">{{ createdKey.rawKey }}</code>
<button class="btn-sm" @click="copyCreatedKey">
<Icon icon="mdi:content-copy" /> 复制
</button>
</div>
<p v-if="keyCopied" class="apikey-copied">已复制到剪贴板</p>
<div class="form-actions">
<button class="btn-sm btn-primary" @click="createdKey = null">完成</button>
</div>
</div>
</div>
</section>
<!-- Audit Log -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">
@@ -1161,6 +1216,7 @@ import { Icon } from '@iconify/vue';
import axios from 'axios';
import AvatarUpload from '../components/AvatarUpload.vue';
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
@@ -2198,6 +2254,90 @@ async function onConfirmReset() {
}
}
// --- API Keys management ---
interface ApiKeyEntry { id: string; name: string; keyPrefix: string; createdAt: number; lastUsedAt: number | null }
const apiKeyList = ref<ApiKeyEntry[]>([]);
const apiKeyLoadError = ref('');
const apiKeyMutationError = ref('');
const newApiKeyName = ref('');
const creatingApiKey = ref(false);
const createdKey = ref<{ key: ApiKeyEntry; rawKey: string } | null>(null);
const keyCopied = ref(false);
async function loadApiKeys() {
apiKeyLoadError.value = '';
try {
const res = await fetch('/api/keys');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
apiKeyList.value = body.keys ?? [];
} catch (e) {
apiKeyLoadError.value = (e as Error).message;
}
}
async function onCreateApiKey() {
const name = newApiKeyName.value.trim();
if (!name) return;
apiKeyMutationError.value = '';
creatingApiKey.value = true;
try {
const res = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
createdKey.value = body;
keyCopied.value = false;
newApiKeyName.value = '';
await loadApiKeys();
} catch (e) {
apiKeyMutationError.value = (e as Error).message;
} finally {
creatingApiKey.value = false;
}
}
async function onRevokeApiKey(k: ApiKeyEntry) {
if (!confirm(`确认吊销 API Key「${k.name}」?使用它的集成将立即失效。`)) return;
apiKeyMutationError.value = '';
try {
const res = await fetch(`/api/keys/${k.id}`, { method: 'DELETE' });
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
await loadApiKeys();
} catch (e) {
apiKeyMutationError.value = (e as Error).message;
}
}
async function copyCreatedKey() {
if (!createdKey.value) return;
const text = createdKey.value.rawKey;
try {
await navigator.clipboard.writeText(text);
keyCopied.value = true;
} catch {
// Clipboard API is unavailable on non-secure origins — fall back to a
// temporary textarea + execCommand.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
keyCopied.value = document.execCommand('copy');
} finally {
document.body.removeChild(ta);
}
}
}
// --- Per-user permission editor (members only) ---
const CAPABILITIES: { token: string; label: string }[] = [
{ token: 'player.control', label: '播放控制' },
@@ -2334,13 +2474,15 @@ function describeAction(e: AuditEntry): string {
case 'user.password_changed': return `修改自己的密码`;
case 'user.role_changed': return `变更 ${target} 的角色`;
case 'user.permissions_changed': return `权限变更 → ${target}`;
case 'api_key.created': return `${target} 生成 API Key`;
case 'api_key.deleted': return `${target} 吊销 API Key`;
default: return `${e.action} → ${target}`;
}
}
function auditActionClass(action: string): string {
if (action === 'user.deleted') return 'audit-action-danger';
if (action === 'user.password_reset' || action === 'user.password_changed') return 'audit-action-warn';
if (action === 'user.password_reset' || action === 'user.password_changed' || action === 'api_key.deleted') return 'audit-action-warn';
return 'audit-action-ok';
}
@@ -2351,6 +2493,7 @@ onMounted(() => {
loadIdleTimeout(); // also populates the Spotify config form (same endpoint)
loadSpotifyStatus();
handleSpotifyRedirect();
loadApiKeys();
if (session.isAdmin.value) {
loadUsers();
loadAudit();
@@ -3146,6 +3289,18 @@ onUnmounted(() => {
.user-add-form .input { flex: 1; min-width: 140px; }
.user-empty, .user-error { font-size: 12px; color: var(--text-secondary); padding: 8px 0; }
.user-error { color: #e26a6a; }
.apikey-hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.6; }
.apikey-hint code { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 4px; padding: 1px 4px; }
.apikey-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.apikey-prefix { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
.apikey-raw-row { display: flex; gap: 8px; align-items: center; }
.apikey-raw {
flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px; word-break: break-all; padding: 8px;
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px;
user-select: all;
}
.apikey-copied { font-size: 12px; color: var(--color-primary); margin: 6px 0 0; }
.modal-hint { color: var(--text-secondary); font-size: 12px; margin: 0 0 8px; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
SEARCH_HISTORY_MAX,
SEARCH_HISTORY_STORAGE_KEY,
clearStoredHistory,
historyForPlatform,
loadHistory,
parseHistory,
pushHistory,
removeHistory,
sameEntry,
saveHistory,
serializeHistory,
type SearchHistoryEntry,
} from "./searchHistory.js";
function memoryStorage(seed: Record<string, string> = {}) {
const map = new Map(Object.entries(seed));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
dump: () => Object.fromEntries(map),
};
}
const entry = (q: string, platform = "netease", at = 1): SearchHistoryEntry => ({ q, platform, at });
describe("search history", () => {
let storage: ReturnType<typeof memoryStorage>;
beforeEach(() => {
storage = memoryStorage();
});
describe("pushHistory", () => {
it("prepends the newest query", () => {
const list = pushHistory([entry("old")], "new", "netease", 2);
expect(list.map((e) => e.q)).toEqual(["new", "old"]);
});
it("trims the query and ignores blank input", () => {
expect(pushHistory([], " hello ", "netease", 2)[0].q).toBe("hello");
expect(pushHistory([entry("keep")], " ", "netease", 2)).toEqual([entry("keep")]);
expect(pushHistory([], "", "netease", 2)).toEqual([]);
});
it("moves a repeated query to the front instead of duplicating it", () => {
const list = pushHistory([entry("b", "netease", 2), entry("a", "netease", 1)], "a", "netease", 3);
expect(list.map((e) => e.q)).toEqual(["a", "b"]);
expect(list[0].at).toBe(3);
});
it("matches repeated queries case-insensitively", () => {
const list = pushHistory([entry("Hello", "netease", 1)], "hello", "netease", 2);
expect(list).toHaveLength(1);
expect(list[0].q).toBe("hello");
});
it("keeps the same query from a different source as its own entry", () => {
const list = pushHistory([entry("hello", "netease", 1)], "hello", "qq", 2);
expect(list).toHaveLength(2);
expect(list.map((e) => e.platform)).toEqual(["qq", "netease"]);
});
it("caps the list at SEARCH_HISTORY_MAX, dropping the oldest", () => {
let list: SearchHistoryEntry[] = [];
for (let i = 1; i <= SEARCH_HISTORY_MAX + 3; i++) {
list = pushHistory(list, `q${i}`, "netease", i);
}
expect(list).toHaveLength(SEARCH_HISTORY_MAX);
expect(list[0].q).toBe(`q${SEARCH_HISTORY_MAX + 3}`);
expect(list.at(-1)?.q).toBe("q4");
});
it("does not mutate the input list", () => {
const list = [entry("a")];
pushHistory(list, "b", "netease", 2);
expect(list.map((e) => e.q)).toEqual(["a"]);
});
});
describe("removeHistory", () => {
it("removes only the matching query+source pair", () => {
const list = [entry("a", "netease"), entry("a", "qq"), entry("b", "netease")];
expect(removeHistory(list, "a", "netease").map((e) => `${e.platform}:${e.q}`)).toEqual([
"qq:a",
"netease:b",
]);
});
it("is case-insensitive on the query", () => {
expect(removeHistory([entry("Hello")], "hello", "netease")).toEqual([]);
});
});
describe("historyForPlatform", () => {
it("returns only the selected source, newest first", () => {
const list = [entry("a", "qq", 3), entry("b", "netease", 2), entry("c", "netease", 1)];
expect(historyForPlatform(list, "netease").map((e) => e.q)).toEqual(["b", "c"]);
expect(historyForPlatform(list, "bilibili")).toEqual([]);
});
});
describe("parse/serialize", () => {
it("round-trips a list", () => {
const list = [entry("a"), entry("b", "qq", 5)];
expect(parseHistory(serializeHistory(list))).toEqual(list);
});
it("returns [] for malformed or non-array payloads", () => {
expect(parseHistory(null)).toEqual([]);
expect(parseHistory("")).toEqual([]);
expect(parseHistory("{not json")).toEqual([]);
expect(parseHistory('{"q":"a"}')).toEqual([]);
});
it("drops entries that are missing required fields", () => {
const raw = JSON.stringify([
{ q: "ok", platform: "netease", at: 1 },
{ q: "", platform: "netease", at: 2 },
{ q: "no-platform", platform: "", at: 3 },
{ q: "no-time", platform: "qq" },
{ q: "not-an-object", platform: 5, at: 4 },
]);
expect(parseHistory(raw).map((e) => e.q)).toEqual(["ok"]);
});
it("truncates over-long stored payloads to the cap", () => {
const raw = JSON.stringify(
Array.from({ length: SEARCH_HISTORY_MAX + 5 }, (_, i) => entry(`q${i}`, "netease", i)),
);
expect(parseHistory(raw)).toHaveLength(SEARCH_HISTORY_MAX);
});
});
describe("storage helpers", () => {
it("round-trips through the injected storage", () => {
saveHistory([entry("persisted", "qq", 9)], storage);
expect(loadHistory(storage)).toEqual([entry("persisted", "qq", 9)]);
expect(Object.keys(storage.dump())).toEqual([SEARCH_HISTORY_STORAGE_KEY]);
});
it("loads [] when the key is absent", () => {
expect(loadHistory(storage)).toEqual([]);
});
it("loads [] instead of throwing on corrupt storage", () => {
const bad = memoryStorage({ [SEARCH_HISTORY_STORAGE_KEY]: "}{" });
expect(loadHistory(bad)).toEqual([]);
});
it("clearStoredHistory removes the key", () => {
saveHistory([entry("a")], storage);
clearStoredHistory(storage);
expect(storage.dump()).toEqual({});
expect(loadHistory(storage)).toEqual([]);
});
it("survives a storage that throws on every access", () => {
const hostile = {
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
removeItem: () => {
throw new Error("denied");
},
};
expect(loadHistory(hostile)).toEqual([]);
expect(() => saveHistory([entry("a")], hostile)).not.toThrow();
expect(() => clearStoredHistory(hostile)).not.toThrow();
});
it("tolerates a null storage (SSR / privacy mode)", () => {
expect(loadHistory(null)).toEqual([]);
expect(() => saveHistory([entry("a")], null)).not.toThrow();
});
});
describe("sameEntry", () => {
it("compares platform and case-insensitive query", () => {
expect(sameEntry(entry("Hello", "qq"), " hello ", "qq")).toBe(true);
expect(sameEntry(entry("Hello", "qq"), "hello", "netease")).toBe(false);
});
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Search history for the search page.
*
* Deliberately client-side (localStorage, per browser): the bot is shared by
* many users, so a server-side history would leak one user's queries into
* another's suggestions. Everything here is a pure function except the three
* storage helpers, which accept an injected Storage-like so the list logic is
* unit-testable without a DOM.
*/
export const SEARCH_HISTORY_MAX = 10;
export const SEARCH_HISTORY_STORAGE_KEY = 'search-history';
export interface SearchHistoryEntry {
q: string;
platform: string;
at: number;
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
function defaultStorage(): StorageLike | null {
try {
return typeof localStorage === 'undefined' ? null : localStorage;
} catch {
// Accessing localStorage throws in some privacy modes.
return null;
}
}
function isEntry(value: unknown): value is SearchHistoryEntry {
if (!value || typeof value !== 'object') return false;
const e = value as Partial<SearchHistoryEntry>;
return (
typeof e.q === 'string' &&
e.q.trim().length > 0 &&
typeof e.platform === 'string' &&
e.platform.length > 0 &&
typeof e.at === 'number'
);
}
export function parseHistory(raw: string | null | undefined): SearchHistoryEntry[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isEntry).slice(0, SEARCH_HISTORY_MAX);
} catch {
return [];
}
}
export function serializeHistory(list: SearchHistoryEntry[]): string {
return JSON.stringify(list.slice(0, SEARCH_HISTORY_MAX));
}
export function sameEntry(
entry: SearchHistoryEntry,
q: string,
platform: string,
): boolean {
return (
entry.platform === platform &&
entry.q.toLowerCase() === q.trim().toLowerCase()
);
}
/**
* Newest first. Repeating a query for the same source moves the existing entry
* to the front instead of adding a duplicate (case-insensitive on the query).
*/
export function pushHistory(
list: SearchHistoryEntry[],
q: string,
platform: string,
at = Date.now(),
): SearchHistoryEntry[] {
const query = q.trim();
if (!query || !platform) return list;
const rest = list.filter((e) => !sameEntry(e, query, platform));
return [{ q: query, platform, at }, ...rest].slice(0, SEARCH_HISTORY_MAX);
}
export function removeHistory(
list: SearchHistoryEntry[],
q: string,
platform: string,
): SearchHistoryEntry[] {
return list.filter((e) => !sameEntry(e, q, platform));
}
/** Entries for the currently selected source, newest first. */
export function historyForPlatform(
list: SearchHistoryEntry[],
platform: string,
): SearchHistoryEntry[] {
return list.filter((e) => e.platform === platform);
}
export function loadHistory(storage: StorageLike | null = defaultStorage()): SearchHistoryEntry[] {
try {
return parseHistory(storage?.getItem(SEARCH_HISTORY_STORAGE_KEY) ?? null);
} catch {
return [];
}
}
export function saveHistory(
list: SearchHistoryEntry[],
storage: StorageLike | null = defaultStorage(),
): void {
try {
storage?.setItem(SEARCH_HISTORY_STORAGE_KEY, serializeHistory(list));
} catch {
// Storage full or unavailable — history is a convenience, not state.
}
}
export function clearStoredHistory(storage: StorageLike | null = defaultStorage()): void {
try {
storage?.removeItem(SEARCH_HISTORY_STORAGE_KEY);
} catch {
// Ignore.
}
}