mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 05:52:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5879ea106 | ||
|
|
44a1457baa | ||
|
|
a4c0239a59 | ||
|
|
5258ba951b | ||
|
|
d16aca2ba6 | ||
|
|
9bcddb1881 | ||
|
|
cf83916f39 | ||
|
|
f8b03acca3 | ||
|
|
b9c79c8138 | ||
|
|
79fb8443be | ||
|
|
c904190912 | ||
|
|
41b81a6193 | ||
|
|
f495ca0ff4 | ||
|
|
bdb33df89d | ||
|
|
3423bf502c | ||
|
|
b6ad536bb7 | ||
|
|
af4ca56fd3 | ||
|
|
bf7858db74 | ||
|
|
e4eea8276a | ||
|
|
aab8a004ae | ||
|
|
87fca6d8b7 | ||
|
|
c7b1268281 | ||
|
|
b942a4726e | ||
|
|
ad728a3a04 | ||
|
|
c51d311ab6 | ||
|
|
ac4a12d8bd | ||
|
|
07ad861ecf | ||
|
|
88e6b5a691 | ||
|
|
6b82df4e50 | ||
|
|
faf6ac09ec | ||
|
|
8ff51ea6e0 | ||
|
|
3c0e8df763 | ||
|
|
2ea02f54d9 | ||
|
|
a804b2edc1 | ||
|
|
5e9ae49f52 | ||
|
|
1407cadf7b | ||
|
|
9fd1b39092 | ||
|
|
af1dac848d | ||
|
|
9fdc164f98 | ||
|
|
19ad48c4ab | ||
|
|
c79a9a6dee | ||
|
|
ea0d7abce3 | ||
|
|
c7b577adba | ||
|
|
7c3926a2ae | ||
|
|
28b3cd771f | ||
|
|
b92543f337 | ||
|
|
990edd1bb0 | ||
|
|
e39ce590c1 | ||
|
|
e027ee3d02 | ||
|
|
06d29ba306 | ||
|
|
03ffd09d36 | ||
|
|
74ea8d26d4 | ||
|
|
cc3684ff86 | ||
|
|
f777d892db | ||
|
|
55695c2d1c | ||
|
|
db2e70fb11 |
No files matched your search
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Node.js-20+-339933?logo=nodedotjs&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Node.js-20%20%7C%2022%20LTS-339933?logo=nodedotjs&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/许可证-MIT-green" />
|
||||
@@ -32,7 +32,7 @@
|
||||
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
|
||||
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
|
||||
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **本地音视频上传播放** — 在搜索页拖拽或选择本地文件上传,音频(mp3 / flac / wav / m4a / ogg / opus 等)和视频(mp4 / mov / avi / mkv / flv / wmv 等)都支持,视频上传后只保留其中的音轨;上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
|
||||
@@ -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站热门视频和个性化推荐(登录后更准确)
|
||||
@@ -62,20 +62,25 @@
|
||||
|
||||
### 方式一:Windows 一键部署(最简单)
|
||||
|
||||
只需电脑有网络连接,其他一切自动安装。
|
||||
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
|
||||
|
||||
```
|
||||
1. 下载或 clone 本项目
|
||||
2. 双击 scripts\setup.bat (首次安装,自动安装 Node.js 和所有依赖)
|
||||
3. 双击 scripts\start.bat (启动机器人)
|
||||
4. 浏览器打开 http://localhost:3000
|
||||
1. 安装 Node.js 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
|
||||
2. 下载或 clone 本项目
|
||||
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
|
||||
4. 双击 scripts\start.bat (启动机器人)
|
||||
5. 浏览器打开 http://localhost:3000
|
||||
```
|
||||
|
||||
> `setup.bat` 会自动通过 winget 安装 Node.js(如果未安装),运行 `npm install` 安装所有依赖(包括内置 FFmpeg),最后构建项目。之后每次只需双击 `start.bat` 启动。
|
||||
> **先装 Node.js 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
|
||||
>
|
||||
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
|
||||
>
|
||||
> **Node 20 已不再支持**:better-sqlite3 从 12.10.0 起不再发布它那个 ABI(115)的预编译包,装起来必须先备好 Python + C++ 构建工具([#152](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/152))。Node 24 及更新的大版本能用,但 @discordjs/opus 0.10.0 同样没有 Node 24(ABI 137)的预编译包,安装脚本会改用源码编译,需要构建工具且耗时更久——所以推荐 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
|
||||
|
||||
### 方式二:手动安装(所有系统)
|
||||
|
||||
**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
|
||||
**前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
|
||||
FFmpeg **已自动内置**,无需手动安装。
|
||||
|
||||
```bash
|
||||
@@ -131,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
|
||||
```
|
||||
|
||||
## 更新升级
|
||||
|
||||
@@ -348,8 +374,8 @@ sudo systemctl start tsmusicbot
|
||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||
| `!play id <id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:<id>` 仍然可用 |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id <id>` / 链接) |
|
||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||
| `!stop` | 停止播放并清空队列 |
|
||||
@@ -359,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(默认网易云,自动续播) |
|
||||
@@ -435,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`。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
@@ -490,6 +563,7 @@ teamspeak-music-bot/
|
||||
├── scripts/ # 部署脚本
|
||||
│ ├── setup.bat # Windows 首次安装
|
||||
│ ├── start.bat # Windows 启动脚本
|
||||
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
|
||||
│ ├── install.sh # Linux 一键安装 + systemd 服务
|
||||
│ └── docker/ # Docker 部署文件
|
||||
│ ├── Dockerfile
|
||||
@@ -505,7 +579,7 @@ teamspeak-music-bot/
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **运行时** | Node.js 20+, TypeScript 5 |
|
||||
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
|
||||
| **后端框架** | Express 4, WebSocket (ws) |
|
||||
| **数据库** | better-sqlite3 (SQLite) |
|
||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||
@@ -564,7 +638,7 @@ teamspeak-music-bot/
|
||||
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
|
||||
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
|
||||
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
|
||||
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
||||
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id <id>` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
||||
|
||||
### enabledProviders:音源开关
|
||||
|
||||
@@ -797,11 +871,14 @@ A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测
|
||||
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
|
||||
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
|
||||
|
||||
**Q:启动报 `NODE_MODULE_VERSION 137 ... requires 127`,或提示找不到 `opus.node`?**
|
||||
A:换过 Node 大版本了。原生模块(`@discordjs/opus`、`better-sqlite3`)编译时绑定了一个 Node ABI(Node 20 = 115、22 = 127、24 = 137),换版本后旧的 `.node` 就再也加载不了。**重新运行一次 `scripts\setup.bat`(Linux/macOS 是 `bash scripts/setup.sh`)即可**——安装脚本会实际加载一遍每个原生模块,发现和当前 Node 不匹配就自动重新下载/编译,替换过程中失败也会把原来的文件还原回去。`start.bat` 和 `npm start` 在启动前也会先做这个检查,直接告诉你哪个模块对不上、分别是哪个 ABI,而不是抛一串看不懂的堆栈。想彻底重来就删掉 `node_modules` 和 `web\node_modules` 再跑一次 `setup.bat`。
|
||||
|
||||
**Q:提示"无法获取播放链接"?**
|
||||
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
||||
|
||||
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
|
||||
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
||||
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id <歌曲id>` 按 id 精确播放(`!search` 结果里每行末尾的 `[id:...]` 就是它);(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
||||
|
||||
**Q:如何更换机器人所在频道?**
|
||||
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
||||
@@ -876,7 +953,123 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
|
||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||
|
||||
### 最新版本 — v1.11.1:修复 `!help` 触发机器人自动点歌
|
||||
### 最新版本 — v1.15.1:长视频播放阻塞与音乐 API 日志隐私修复
|
||||
|
||||
- 持续读取 FFmpeg 的 stderr,并关闭周期性进度输出,避免错误输出管道写满后卡住音频解码。直接 URL 播放和 Windows 临时文件播放均已处理。
|
||||
- FFmpeg 异常退出和播放停滞日志增加限长、脱敏的诊断摘要;移除 URL 查询参数、用户凭据和认证头,PowerShell 下载失败日志采用同样的处理。
|
||||
- 内置网易云 / QQ 音乐 API 在独立子进程运行,隔离依赖直接输出的原始请求和响应日志,避免其中的 Cookie 等凭据进入机器人控制台日志。仍保留服务启动和退出的安全诊断;独立部署或已占用端口的外部 API 需自行管理日志。
|
||||
|
||||
无配置或数据库迁移。此补丁修复了已复现的管道阻塞;[#161](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/161) 中“67 分钟视频播到 37 分钟停止”的现场原因仍缺少停止时日志,尚未确认。
|
||||
|
||||
### v1.15.0:歌手页面 / REST API / TS6 Profile 与 B站续播修复
|
||||
|
||||
**歌手搜索与页面([PR #175](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/175),感谢 [@zzstar101](https://github.com/zzstar101))**
|
||||
|
||||
- 网易云 / QQ 音乐支持搜索歌手、查看歌手介绍、热门歌曲和专辑,并播放或随机播放歌手曲目(最多 500 首)。搜索历史按音源保存在当前浏览器。
|
||||
- 歌手播放与单曲播放共用播放锁,避免同时点播时实际歌曲与队列不一致。QQ 曲目目录在上游查询失败时不缓存降级结果,不再因 50 张专辑的限制提前截断歌曲。
|
||||
- 歌手页面的迟到请求不会覆盖新页面;访客的随机播放按钮同时遵守歌手播放与模式切换权限。
|
||||
|
||||
**REST API([PR #173](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/173),感谢 [@senlinjun](https://github.com/senlinjun))**
|
||||
|
||||
- 在设置页创建、查看和撤销 API Key;脚本可用 Bearer 或 X-API-Key 调用已有 REST 端点,权限和可控机器人范围继承所属用户。完整说明见 [REST API 文档](docs/API.md)。
|
||||
- 修复管理员撤销他人 Key 时的审计对象。修改或重置密码会撤销该用户的全部 API Key,外部集成需要重新生成凭据。
|
||||
|
||||
**TS6 Profile([PR #174](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/174),感谢 [@razaxq](https://github.com/razaxq))**
|
||||
|
||||
- 昵称和 Away 状态通过真实音乐客户端更新,描述通过明确的客户端 ID 更新,避免修改 HTTP ServerQuery 客户端。
|
||||
- 频道描述写入和移动后的清理使用相同权限路径;重连后丢弃旧会话请求,权限不足时可靠降级。
|
||||
|
||||
**B站长视频续播([#161](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/161),[PR #170](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/170))**
|
||||
|
||||
- 优先使用稳定 CDN 镜像;流提前结束时重新解析地址并从当前进度续播,连续无进展重试有次数限制。
|
||||
- 播放结束和恢复请求按播放会话校验,旧请求不会跳过新曲,也不会覆盖同一曲目的新一轮播放。
|
||||
- 恢复地址查询期间暂停会保留暂停状态;恢复播放不会重复发起查询,查询失败后仍可按重试上限继续恢复。
|
||||
|
||||
数据库自动新增 API Key 表,保留已有用户和设置。自动化测试只收集源码,排除旧的编译测试副本。
|
||||
|
||||
### v1.14.0:歌单链接直接播放 / 每人绑定自己的网易云私人FM / B站分P
|
||||
|
||||
处理了 5 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;数据库会自动新增一张表(存放用户自己绑定的网易云账号),原有数据不受影响。
|
||||
|
||||
**`!playlist` 直接粘贴歌单链接([#160](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/160),[PR #169](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/169),感谢 [@JiaxiangACE](https://github.com/JiaxiangACE))**
|
||||
|
||||
- `!playlist <歌单链接>` 支持网易云 / QQ 音乐 / YouTube 歌单链接,**平台由链接自动识别**:以前 QQ 链接不加 `-q` 会被拿去网易云查,YouTube 的 `?list=` 链接会被当成歌单名去搜索,现在都能直接用。
|
||||
- App 里「分享」复制出来的整段文案、以及短链(`163cn.tv`、`c6.y.qq.com`)也能直接粘贴。短链只会访问这两个域名,不会去请求任意用户给的地址。
|
||||
- 歌单名和纯数字 ID 的用法不变。
|
||||
|
||||
**每个网页端用户绑定自己的网易云账号听私人FM([#164](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/164),[PR #171](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/171),感谢 [@xxmod](https://github.com/xxmod))**
|
||||
|
||||
- 多人共用一个机器人时,私人FM以前永远按机器人登录的那一个账号推荐。现在每个成员可以在 **设置 → 账户** 扫码绑定自己的网易云账号,之后他在网页端开启的网易云私人FM按他自己的口味推荐;未绑定的人照旧使用共享账号。
|
||||
- 绑定的登录只保存在服务器上,从不回传给浏览器,也**不会**顶掉机器人的共享登录;删除用户时一并清除。游客不能绑定。
|
||||
- TS 聊天里的 `!fm` 仍使用共享账号(聊天里的 TS 用户和网页账号没有对应关系)。
|
||||
|
||||
**机器人被移动后,原频道描述不再残留([#159](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/159),[PR #168](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/168),感谢 [@Almighty-ap](https://github.com/Almighty-ap))**
|
||||
|
||||
- 开启「更新频道描述」时,把机器人拖到别的频道后,原频道会一直停留在当时的歌曲信息。现在机器人被移动时会清空原频道描述,并把正在播放的信息写到新频道。
|
||||
|
||||
**Linux 安装脚本([#165](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/165),[PR #172](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/172),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
|
||||
|
||||
- `scripts/install.sh` 以前仍在安装已不再支持的 Node 20,现在按发行版(apt / yum / pacman)安装 Node 22 LTS,并复用 `setup.sh` 完成依赖安装、国内镜像切换、原生模块校验和构建。重复运行(升级)时会先停服务、替换构建产物,**保留 `data/`**。
|
||||
- README 的「Linux 安装脚本」一节说明了 `install.sh`(一键安装 + systemd 开机自启)和 `setup.sh`(只安装构建、不装 Node、不配服务)的区别和适用场景。
|
||||
|
||||
**B站分P视频([PR #166](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/166),感谢 [@xxmod](https://github.com/xxmod))**
|
||||
|
||||
- 分P视频以前只能播放第一P,且时长显示为整个视频的总时长。现在网页端播放多P视频时会弹出选择框选P;TS 里 `!play` 播放第一P。
|
||||
|
||||
### v1.13.0:本地视频上传播放 / 头像上传时机
|
||||
|
||||
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
|
||||
|
||||
**本地视频上传播放([#149](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/149),[PR #151](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/151),感谢 [@LadenceE](https://github.com/LadenceE))**
|
||||
|
||||
- 搜索页的本地上传现在也收**视频文件**:mp4 / mov / avi / mkv / flv / wmv / m4v / mpg / mpeg / 3gp / ts / m2ts / ogv。上传后当普通歌曲用——直接播放、下一首播放、加入队列都一样。
|
||||
- 视频**只保留音轨**:上传后立刻把音频流原样搬进一个音频容器(不重编码、无损),再删掉原视频。720p 素材实测落盘只剩原文件的 14%,不然十几个视频就把 5 GiB 的上传目录配额占满了。
|
||||
- 没有音轨的视频会在**上传时**就被拒绝并说明原因,而不是排进队列后静默跳过。
|
||||
- 单文件上限从 200 MB 提到 **500 MB**;超限时的报错从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成正常的中文提示,浏览器端也会在开传前就拦下超大文件。
|
||||
- 上传进度按文件显示百分比,传完切到「服务端处理中」——视频比音频大得多,原先那句静止的「正在上传」看着像卡死。
|
||||
- 说明:这里做的是「把你本地磁盘上的文件传上来播放」。让机器人直接读取**服务器**磁盘上任意路径的文件没有做——那等于开一个全盘任意文件读取的口子,而「播放服务器上已有的媒体库」用 Jellyfin 音源即可。
|
||||
|
||||
**初始化阶段不再发起注定失败的头像上传([#148](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/148),[PR #150](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/150),感谢 [@shenmu-rua](https://github.com/shenmu-rua))**
|
||||
|
||||
- 机器人构造阶段读出已保存的自定义头像后会立刻发起文件传输,但那时 TeamSpeak 还没连上,这次传输必定失败。现在构造阶段只把头像数据装入内存,实际上传交给连接成功后的 `onConnect()`。
|
||||
- **影响范围说明**:头像本身一直是能正常显示的(连接成功后本来就会重新应用一次),所以这不是「头像丢了」。真正的代价是每次启动 / 重启都会多一次注定失败的请求和一条 `Profile update failed` 警告日志——现在没有了。
|
||||
- 顺带修掉一个边角:头像文件写到一半崩溃会留下 0 字节文件,原先这会再触发两个同样注定失败的请求。
|
||||
|
||||
### v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈
|
||||
|
||||
一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。
|
||||
|
||||
**安装脚本按 Node ABI 校验并自动修复([#140](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/140),[PR #147](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/147),感谢 [@zbn297427669](https://github.com/zbn297427669))**
|
||||
|
||||
- 换过 Node 大版本后启动报 `NODE_MODULE_VERSION 137 ... requires 127`、或提示找不到 `opus.node` 的问题已修复。原生模块只能在编译它的 Node ABI 上加载,而旧脚本只检查「文件存在且够大」,会把给另一个 Node 版本编译的二进制原样留下。
|
||||
- 现在安装脚本会在子进程里真的加载一遍每个原生模块,不匹配就按当前 ABI 重新安装;替换过程先备份再原子替换,任何一步失败都会把原文件逐字节还原,不会让环境变得更糟。被中断留下的备份,下次运行自动认领回来。
|
||||
- 必需模块失败会**中止安装并返回非零**,不再出现「setup 显示成功、start 才爆炸」;下载进度实时显示在控制台,不再让人以为卡死。
|
||||
- `start.bat` 和 `npm start` 启动前会预检,直接说清楚哪个模块对不上、分别是哪个 ABI、怎么修。
|
||||
- 顺带修掉一个会**静默丢掉 ffmpeg** 的问题:源码编译会阻塞事件循环,把同时进行的 80MB ffmpeg 下载误判为超时,而 ffmpeg 是可选模块,于是安装照样报告成功、用户却放不出任何声音。三个模块改为串行处理。
|
||||
- Node 版本要求按依赖真实下限判断(20.19+ / 22.12+),推荐 20 或 22 LTS;更新的大版本不阻止,只提示可能需要源码编译。
|
||||
|
||||
**随机模式下 `!pn` 真正下一首播放([#141](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/141),[PR #144](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/144),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
|
||||
|
||||
- 随机 / 随机循环下 `!pn`(以及 WebUI 的「下一首播放」)插入的歌只是和其他歌一样等着被随机抽中,机器人却回复「Up next」。现在会真的下一首播放,连续插入多首时的顺序与队列里显示的一致。
|
||||
- 同时修掉两个相关问题:插入或删除队列中的歌之后,待播位置可能指向另一首歌;删得多了甚至会让播放**静默停止**。
|
||||
|
||||
**WebUI 站点图标与移动端交互([#142](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/142) / [#143](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/143) / [#138](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/138),[PR #146](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/146),感谢 [@XuVIIJay](https://github.com/XuVIIJay) 与 [@hak5ya](https://github.com/hak5ya))**
|
||||
|
||||
- 新增站点图标:收藏网页、移动端添加到主屏幕都会显示图标(含 iOS 与 Android 适配)。
|
||||
- 移动端迷你播放器的进度条现在**可以点按和拖动调节进度**,触摸区域也放大到可用尺寸,拖动时不会被自动跳转到歌词页。
|
||||
- 移动端**单击歌曲行即可播放**(桌面端双击行为不变);队列抽屉里的歌曲行同样支持,其移除按钮在触屏下不再是「看不见但点得到」。
|
||||
- QQ 扫码登录的提示改为「请使用手机QQ扫码」——那是 QQ 账号二维码,用 QQ音乐 APP 扫不出来。
|
||||
|
||||
**`!play id <id>` 与其他命令语法统一([#139](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/139),[PR #145](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/145),感谢 [@hak5ya](https://github.com/hak5ya))**
|
||||
|
||||
- 按 id 播放现在可以写成 `!play id <id>`,和其他命令的 `<命令> <子命令> <参数>` 形式一致;`!add` / `!playnext` 同样适用。
|
||||
- **旧写法 `!play id:<id>` 继续支持**。空格写法只在参数确实像 id 时生效,普通搜索和粘贴链接的行为不受影响。
|
||||
|
||||
### v1.11.2 — 可配置语音闪避
|
||||
|
||||
- 检测频道内其他人说话时平滑降低音乐音量,停止后平滑恢复;默认关闭,可在设置页启用并调节说话时保留的音量比例([#136](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/136),[PR #137](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/137))。
|
||||
|
||||
### v1.11.1:修复 `!help` 触发机器人自动点歌
|
||||
|
||||
**丢弃自回显消息([PR #135](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/135),感谢 [@EvolvedGhost](https://github.com/EvolvedGhost))**
|
||||
|
||||
|
||||
+374
@@ -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.
|
||||
@@ -3,10 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && npm run build:web",
|
||||
"build:web": "cd web && npm run build",
|
||||
"check:native": "node scripts/check-native.mjs",
|
||||
"prestart": "node scripts/check-native.mjs",
|
||||
"start": "node dist/index.js",
|
||||
"play": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts
|
||||
index b2adb3d..ecc2202 100644
|
||||
--- a/src/music/netease.test.ts
|
||||
+++ b/src/music/netease.test.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
-import { parseLyrics } from "./netease.js";
|
||||
+import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
|
||||
|
||||
describe("NetEase adapter", () => {
|
||||
it("parses LRC format lyrics", () => {
|
||||
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
|
||||
expect(lines[0].text).toBe("Hello world");
|
||||
expect(lines[0].translation).toBe("你好世界");
|
||||
});
|
||||
+
|
||||
+ it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
|
||||
+ const raw = [
|
||||
+ {
|
||||
+ id: 42,
|
||||
+ name: "Album A",
|
||||
+ picUrl: "https://x/p.jpg",
|
||||
+ artists: [{ name: "Artist X" }, { name: "Featured Y" }],
|
||||
+ size: 12,
|
||||
+ },
|
||||
+ {
|
||||
+ id: 99,
|
||||
+ name: "Album B",
|
||||
+ picUrl: "",
|
||||
+ artists: [],
|
||||
+ },
|
||||
+ ];
|
||||
+ expect(mapNeteaseAlbums(raw)).toEqual([
|
||||
+ { id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
|
||||
+ { id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
|
||||
+ ]);
|
||||
+ });
|
||||
+
|
||||
+ it("mapNeteaseAlbums returns [] for empty/null input", () => {
|
||||
+ expect(mapNeteaseAlbums([])).toEqual([]);
|
||||
+ expect(mapNeteaseAlbums(null as any)).toEqual([]);
|
||||
+ expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
|
||||
+ });
|
||||
});
|
||||
diff --git a/src/music/netease.ts b/src/music/netease.ts
|
||||
index 8aaeab6..4a863db 100644
|
||||
--- a/src/music/netease.ts
|
||||
+++ b/src/music/netease.ts
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SearchResult,
|
||||
QrCodeResult,
|
||||
AuthStatus,
|
||||
+ Album,
|
||||
} from "./provider.js";
|
||||
|
||||
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
|
||||
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
+export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
|
||||
+ if (!Array.isArray(raw)) return [];
|
||||
+ return raw.map((a) => ({
|
||||
+ id: String(a.id),
|
||||
+ name: a.name ?? "",
|
||||
+ artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
|
||||
+ coverUrl: a.picUrl ?? "",
|
||||
+ songCount: a.size ?? 0,
|
||||
+ platform: "netease",
|
||||
+ }));
|
||||
+}
|
||||
+
|
||||
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
|
||||
export const NETEASE_QUALITY_LEVELS = [
|
||||
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
|
||||
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
- const [songRes, playlistRes] = await Promise.all([
|
||||
+ const [songRes, playlistRes, albumRes] = await Promise.all([
|
||||
this.api.get("/cloudsearch", {
|
||||
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
||||
}),
|
||||
@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider {
|
||||
...this.cookieParams,
|
||||
},
|
||||
}),
|
||||
+ this.api.get("/cloudsearch", {
|
||||
+ params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
|
||||
+ }),
|
||||
]);
|
||||
|
||||
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
|
||||
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
|
||||
platform: "netease",
|
||||
}));
|
||||
|
||||
- return { songs, playlists, albums: [] };
|
||||
+ const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
|
||||
+
|
||||
+ return { songs, playlists, albums };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||
diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..4f606cf
|
||||
--- /dev/null
|
||||
+++ b/src/music/qq.test.ts
|
||||
@@ -0,0 +1,43 @@
|
||||
+import { describe, it, expect } from "vitest";
|
||||
+import { mapQqAlbums } from "./qq.js";
|
||||
+
|
||||
+describe("QQ adapter", () => {
|
||||
+ it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||
+ const raw = [
|
||||
+ {
|
||||
+ albumMID: "abc",
|
||||
+ albumName: "Aero",
|
||||
+ singerName: "Singer A",
|
||||
+ },
|
||||
+ {
|
||||
+ albumMID: "xyz",
|
||||
+ albumName: "Beta",
|
||||
+ singer: [{ name: "Singer B" }, { name: "Singer C" }],
|
||||
+ },
|
||||
+ ];
|
||||
+ const out = mapQqAlbums(raw);
|
||||
+ expect(out).toHaveLength(2);
|
||||
+ expect(out[0]).toMatchObject({
|
||||
+ id: "abc",
|
||||
+ name: "Aero",
|
||||
+ artist: "Singer A",
|
||||
+ platform: "qq",
|
||||
+ });
|
||||
+ expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
|
||||
+ expect(out[1].artist).toBe("Singer B / Singer C");
|
||||
+ expect(out[1].coverUrl).toContain("xyz");
|
||||
+ });
|
||||
+
|
||||
+ it("mapQqAlbums returns [] for empty/null input", () => {
|
||||
+ expect(mapQqAlbums([])).toEqual([]);
|
||||
+ expect(mapQqAlbums(null as any)).toEqual([]);
|
||||
+ expect(mapQqAlbums(undefined as any)).toEqual([]);
|
||||
+ });
|
||||
+
|
||||
+ it("mapQqAlbums falls back to albumPic when no albumMID", () => {
|
||||
+ const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
|
||||
+ const out = mapQqAlbums(raw);
|
||||
+ expect(out[0].coverUrl).toBe("https://x/p.jpg");
|
||||
+ expect(out[0].id).toBe("");
|
||||
+ });
|
||||
+});
|
||||
diff --git a/src/music/qq.ts b/src/music/qq.ts
|
||||
index 9c0360d..1e7a8ae 100644
|
||||
--- a/src/music/qq.ts
|
||||
+++ b/src/music/qq.ts
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SearchResult,
|
||||
QrCodeResult,
|
||||
AuthStatus,
|
||||
+ Album,
|
||||
} from "./provider.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
@@ -27,6 +28,26 @@ const qqFavApi = axios.create({
|
||||
headers: { referer: "https://y.qq.com/" },
|
||||
});
|
||||
|
||||
+export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||
+ if (!Array.isArray(raw)) return [];
|
||||
+ return raw.map((a) => {
|
||||
+ const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
|
||||
+ const artist = a.singerName
|
||||
+ ?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
|
||||
+ const coverUrl = id
|
||||
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
|
||||
+ : (a.albumPic ?? "");
|
||||
+ return {
|
||||
+ id,
|
||||
+ name: a.albumName ?? a.title ?? "",
|
||||
+ artist,
|
||||
+ coverUrl,
|
||||
+ songCount: a.song_count ?? a.songCount ?? 0,
|
||||
+ platform: "qq" as const,
|
||||
+ };
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
function computeGtk(pSkey: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < pSkey.length; i++) {
|
||||
@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider {
|
||||
req_0: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
- param: {
|
||||
- searchid: "1",
|
||||
- query,
|
||||
- num_per_page: Math.min(limit, 50),
|
||||
- },
|
||||
+ param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
|
||||
+ },
|
||||
+ req_album: {
|
||||
+ module: "music.search.SearchCgiService",
|
||||
+ method: "DoSearchForQQMusicDesktop",
|
||||
+ param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
|
||||
},
|
||||
});
|
||||
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
|
||||
@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider {
|
||||
platform: "qq",
|
||||
}));
|
||||
|
||||
- return { songs, playlists: [], albums: [] };
|
||||
+ const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
+ const albums = mapQqAlbums(albumList);
|
||||
+
|
||||
+ return { songs, playlists: [], albums };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
|
||||
index b08f9a2..edf9c04 100644
|
||||
--- a/src/web/api/music.ts
|
||||
+++ b/src/web/api/music.ts
|
||||
@@ -52,14 +52,20 @@ export function createMusicRouter(
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
- ...(neteaseResult.status === "fulfilled"
|
||||
- ? neteaseResult.value.songs
|
||||
- : []),
|
||||
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
];
|
||||
+ const albums = [
|
||||
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
+ ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
|
||||
+ ];
|
||||
+ const playlists = [
|
||||
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||
+ ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
||||
+ ];
|
||||
|
||||
- res.json({ songs });
|
||||
+ res.json({ songs, albums, playlists });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Unified search failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
|
||||
index a9af600..4f0930b 100644
|
||||
--- a/src/web/api/player.ts
|
||||
+++ b/src/web/api/player.ts
|
||||
@@ -313,6 +313,78 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
+ // Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||
+ router.post("/:botId/play-album", async (req, res) => {
|
||||
+ try {
|
||||
+ const bot = (req as any).bot;
|
||||
+ const { albumId, platform } = req.body;
|
||||
+ const provider = bot.getProviderFor(
|
||||
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
+ ? platform
|
||||
+ : "netease"
|
||||
+ );
|
||||
+
|
||||
+ // Stop current playback
|
||||
+ bot.getPlayer().stop();
|
||||
+ bot.getPlayer().resetFailures();
|
||||
+
|
||||
+ const songs = await provider.getAlbumSongs(albumId);
|
||||
+ if (songs.length === 0) {
|
||||
+ res.json({ message: "Album is empty" });
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ // QQ-specific optimization: batch-resolve playable IDs to avoid
|
||||
+ // wasting retries on region/copyright-restricted tracks.
|
||||
+ let queueable: { id: string }[] = songs;
|
||||
+ const totalCount = songs.length;
|
||||
+ const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
|
||||
+ if (typeof qqLike.getPlayableSongIds === "function") {
|
||||
+ const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
|
||||
+ if (playable !== null) {
|
||||
+ queueable = songs.filter((s: { id: string }) => playable.has(s.id));
|
||||
+ }
|
||||
+ }
|
||||
+ if (queueable.length === 0) {
|
||||
+ res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ const queue = bot.getQueueManager();
|
||||
+ queue.clear();
|
||||
+ for (const song of queueable) {
|
||||
+ queue.add({ ...song, platform: provider.platform });
|
||||
+ }
|
||||
+
|
||||
+ const mode = queue.getMode();
|
||||
+ let first;
|
||||
+ if (mode === "random" || mode === "rloop") {
|
||||
+ const idx = Math.floor(Math.random() * queue.size());
|
||||
+ first = queue.playAt(idx);
|
||||
+ } else {
|
||||
+ first = queue.play();
|
||||
+ }
|
||||
+
|
||||
+ let started = first ? await bot.resolveAndPlay(first) : false;
|
||||
+ if (first && !started) {
|
||||
+ started = await bot.playNext(20);
|
||||
+ }
|
||||
+
|
||||
+ const playing = queue.current();
|
||||
+ const loadedMsg = queueable.length < totalCount
|
||||
+ ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
|
||||
+ : `已加载 ${queueable.length} 首`;
|
||||
+ if (started && playing) {
|
||||
+ res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
|
||||
+ } else {
|
||||
+ res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
|
||||
+ }
|
||||
+ } catch (err) {
|
||||
+ logger.error({ err }, "play-album failed");
|
||||
+ res.status(500).json({ error: (err as Error).message });
|
||||
+ }
|
||||
+ });
|
||||
+
|
||||
// Play a single song by ID — resolves URL on demand
|
||||
router.post("/:botId/play-song", async (req, res) => {
|
||||
try {
|
||||
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
|
||||
index cc060f5..d62afcd 100644
|
||||
--- a/web/src/router/index.ts
|
||||
+++ b/web/src/router/index.ts
|
||||
@@ -22,6 +22,13 @@ const router = createRouter({
|
||||
path: '/playlist/:id',
|
||||
name: 'playlist',
|
||||
component: () => import('../views/Playlist.vue'),
|
||||
+ meta: { kind: 'playlist' },
|
||||
+ },
|
||||
+ {
|
||||
+ path: '/album/:id',
|
||||
+ name: 'album',
|
||||
+ component: () => import('../views/Playlist.vue'),
|
||||
+ meta: { kind: 'album' },
|
||||
},
|
||||
{
|
||||
path: '/lyrics',
|
||||
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
|
||||
index 9bc817d..083262c 100644
|
||||
--- a/web/src/stores/player.ts
|
||||
+++ b/web/src/stores/player.ts
|
||||
@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', {
|
||||
this._syncAfterAction();
|
||||
},
|
||||
|
||||
+ async playAlbum(albumId: string, platform = 'netease') {
|
||||
+ if (!this.activeBotId) return;
|
||||
+ const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
|
||||
+ if (res.data?.message) {
|
||||
+ this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
+ }
|
||||
+ this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
+ this._syncAfterAction();
|
||||
+ },
|
||||
+
|
||||
async pause() {
|
||||
if (!this.activeBotId) return;
|
||||
// Freeze elapsed at current interpolated value
|
||||
diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue
|
||||
index d5c9f8d..d00101d 100644
|
||||
--- a/web/src/views/Playlist.vue
|
||||
+++ b/web/src/views/Playlist.vue
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
- <div v-else class="loading">歌单不存在或加载失败</div>
|
||||
+ <div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -64,6 +64,8 @@ interface PlaylistDetail {
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
+const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
|
||||
+
|
||||
const playlist = ref<PlaylistDetail | null>(null);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -71,20 +73,32 @@ const loading = ref(true);
|
||||
async function playAll() {
|
||||
const id = route.params.id as string;
|
||||
const platform = (route.query.platform as string) || 'netease';
|
||||
- await store.playPlaylist(id, platform);
|
||||
+ if (kind === 'album') {
|
||||
+ await store.playAlbum(id, platform);
|
||||
+ } else {
|
||||
+ await store.playPlaylist(id, platform);
|
||||
+ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
const platform = (route.query.platform as string) || 'netease';
|
||||
|
||||
+ const detailUrl = kind === 'album'
|
||||
+ ? `/api/music/album/${id}/detail`
|
||||
+ : `/api/music/playlist/${id}/detail`;
|
||||
+ const songsUrl = kind === 'album'
|
||||
+ ? `/api/music/album/${id}`
|
||||
+ : `/api/music/playlist/${id}`;
|
||||
+
|
||||
// allSettled, not Promise.all — if detail 404s but songs is fine
|
||||
// (e.g., a QQ playlist whose detail endpoint flaked but the song
|
||||
// list resolved), we still want to show the songs rather than
|
||||
- // the "歌单不存在" empty state.
|
||||
+ // the "不存在" empty state. For albums, detail always 404s — that
|
||||
+ // is intentional; the fallback stub below handles it.
|
||||
const [detailRes, songsRes] = await Promise.allSettled([
|
||||
- axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
|
||||
- axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
|
||||
+ axios.get(detailUrl, { params: { platform } }),
|
||||
+ axios.get(songsUrl, { params: { platform } }),
|
||||
]);
|
||||
|
||||
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
|
||||
@@ -96,7 +110,7 @@ onMounted(async () => {
|
||||
// Fall back to a stub built from the route + first song's cover.
|
||||
playlist.value = {
|
||||
id,
|
||||
- name: '歌单',
|
||||
+ name: kind === 'album' ? '专辑' : '歌单',
|
||||
description: '',
|
||||
coverUrl: songList[0]?.coverUrl ?? '',
|
||||
songCount: songList.length,
|
||||
@@ -104,7 +118,7 @@ onMounted(async () => {
|
||||
} else {
|
||||
playlist.value = null;
|
||||
if (detailRes.status === 'rejected') {
|
||||
- console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
|
||||
+ console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
|
||||
}
|
||||
}
|
||||
songs.value = songList;
|
||||
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
|
||||
index 0536e36..8822277 100644
|
||||
--- a/web/src/views/Search.vue
|
||||
+++ b/web/src/views/Search.vue
|
||||
@@ -20,22 +20,54 @@
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
- <div v-else-if="results.length > 0" class="results">
|
||||
- <SongCard
|
||||
- v-for="(song, i) in results"
|
||||
- :key="`${song.platform}-${song.id}`"
|
||||
- :song="song"
|
||||
- :index="i + 1"
|
||||
- :active="store.currentSong?.id === song.id"
|
||||
- @play="store.playSong(song)"
|
||||
- @playNext="store.playNextSong(song)"
|
||||
- @add="store.addSong(song)"
|
||||
- />
|
||||
- </div>
|
||||
+ <template v-else-if="songs.length || albums.length || playlists.length">
|
||||
+ <section v-if="albums.length" class="result-section">
|
||||
+ <h2 class="section-title">专辑</h2>
|
||||
+ <div class="card-grid">
|
||||
+ <router-link
|
||||
+ v-for="al in albums"
|
||||
+ :key="`${al.platform}-${al.id}`"
|
||||
+ :to="`/album/${al.id}?platform=${al.platform}`"
|
||||
+ class="card hover-scale"
|
||||
+ >
|
||||
+ <CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
+ <div class="card-name">{{ al.name }}</div>
|
||||
+ <div class="card-sub">{{ al.artist }}</div>
|
||||
+ </router-link>
|
||||
+ </div>
|
||||
+ </section>
|
||||
+
|
||||
+ <section v-if="playlists.length" class="result-section">
|
||||
+ <h2 class="section-title">歌单</h2>
|
||||
+ <div class="card-grid">
|
||||
+ <router-link
|
||||
+ v-for="pl in playlists"
|
||||
+ :key="`${pl.platform}-${pl.id}`"
|
||||
+ :to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
+ class="card hover-scale"
|
||||
+ >
|
||||
+ <CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
+ <div class="card-name">{{ pl.name }}</div>
|
||||
+ </router-link>
|
||||
+ </div>
|
||||
+ </section>
|
||||
+
|
||||
+ <section v-if="songs.length" class="result-section">
|
||||
+ <h2 class="section-title">单曲</h2>
|
||||
+ <SongCard
|
||||
+ v-for="(song, i) in songs"
|
||||
+ :key="`${song.platform}-${song.id}`"
|
||||
+ :song="song"
|
||||
+ :index="i + 1"
|
||||
+ :active="store.currentSong?.id === song.id"
|
||||
+ @play="store.playSong(song)"
|
||||
+ @playNext="store.playNextSong(song)"
|
||||
+ @add="store.addSong(song)"
|
||||
+ />
|
||||
+ </section>
|
||||
+ </template>
|
||||
|
||||
- <div v-else-if="searched" class="empty">
|
||||
- 未找到相关结果
|
||||
- </div>
|
||||
+ <div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,15 +77,21 @@ import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
+import type { Song } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
+import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
-import { Song } from '../stores/player.js';
|
||||
|
||||
-const results = ref<Song[]>([]);
|
||||
+interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
+interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
+
|
||||
+const songs = ref<Song[]>([]);
|
||||
+const albums = ref<Album[]>([]);
|
||||
+const playlists = ref<Playlist[]>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
|
||||
@@ -62,12 +100,12 @@ async function doSearch() {
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
try {
|
||||
- const res = await axios.get('/api/music/search/all', {
|
||||
- params: { q: query.value },
|
||||
- });
|
||||
- results.value = res.data.songs;
|
||||
+ const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||
+ songs.value = res.data.songs ?? [];
|
||||
+ albums.value = res.data.albums ?? [];
|
||||
+ playlists.value = res.data.playlists ?? [];
|
||||
} catch {
|
||||
- results.value = [];
|
||||
+ songs.value = []; albums.value = []; playlists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -141,4 +179,23 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
+
|
||||
+.result-section {
|
||||
+ margin-bottom: 32px;
|
||||
+ .section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
|
||||
+}
|
||||
+.card-grid {
|
||||
+ display: grid;
|
||||
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
+ gap: 16px;
|
||||
+}
|
||||
+.card {
|
||||
+ display: flex;
|
||||
+ flex-direction: column;
|
||||
+ gap: 6px;
|
||||
+ text-decoration: none;
|
||||
+ color: inherit;
|
||||
+ .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
|
||||
+ .card-sub { font-size: 12px; opacity: 0.6; }
|
||||
+}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Preflight: can THIS Node build actually load the native modules that are
|
||||
* sitting in node_modules?
|
||||
*
|
||||
* A compiled addon is tied to one Node ABI (process.versions.modules:
|
||||
* Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major,
|
||||
* launch under another, and the bot dies deep inside startup with a
|
||||
* `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it.
|
||||
* This script turns that into one actionable sentence, before anything starts.
|
||||
*
|
||||
* Exit code:
|
||||
* 0 every required native module loads (or is simply not installed yet —
|
||||
* that is npm install's problem, not an ABI problem)
|
||||
* 1 a required native module definitively fails to load; the bot could not
|
||||
* have started anyway, so there is no false-positive risk here.
|
||||
*
|
||||
* Usage: node scripts/check-native.mjs
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createLineWriter } from "./lib/console-log.mjs";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const NODE_MODULES = join(ROOT, "node_modules");
|
||||
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
|
||||
const NODE_ABI = process.versions.modules;
|
||||
|
||||
/** Only the modules the bot cannot start without. ffmpeg-static is optional
|
||||
* (a system ffmpeg on PATH works too), so it is not checked here. */
|
||||
const REQUIRED = ["@discordjs/opus", "better-sqlite3"];
|
||||
|
||||
function pkgDirOf(spec) {
|
||||
return join(NODE_MODULES, ...spec.split("/"));
|
||||
}
|
||||
|
||||
function summarizeError(text) {
|
||||
const lines = String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
|
||||
return (interesting || lines[0] || "unknown error").slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippet that actually forces each package's addon to be dlopen()ed.
|
||||
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor,
|
||||
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI
|
||||
* binary. Opening an in-memory database is the cheapest way to really load it.
|
||||
*/
|
||||
const PROBE_EXPR = {
|
||||
"@discordjs/opus": "require('@discordjs/opus')",
|
||||
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
|
||||
};
|
||||
|
||||
/**
|
||||
* Load-probe in a throwaway child process. Child process on purpose: requiring
|
||||
* an addon in this process would keep the DLL mapped, and Windows then refuses
|
||||
* to let setup.bat replace the file we just told the user to replace.
|
||||
*/
|
||||
function probeRequire(spec) {
|
||||
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
|
||||
try {
|
||||
execFileSync(process.execPath, ["-e", expr], {
|
||||
cwd: ROOT,
|
||||
stdio: "pipe",
|
||||
timeout: 120000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
|
||||
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
|
||||
// requires NODE_MODULE_VERSION 127..." -> first number is the build target.
|
||||
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
|
||||
return {
|
||||
ok: false,
|
||||
abiMismatch: abis.length >= 2,
|
||||
compiledAbi: abis.length >= 2 ? abis[0] : null,
|
||||
error: summarizeError(text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readStamp() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(STAMP_FILE, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh";
|
||||
|
||||
const broken = [];
|
||||
for (const spec of REQUIRED) {
|
||||
if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job
|
||||
const probe = probeRequire(spec);
|
||||
if (!probe.ok) broken.push({ spec, ...probe });
|
||||
}
|
||||
|
||||
function report() {
|
||||
const stamp = readStamp();
|
||||
const mismatch = broken.find((b) => b.abiMismatch);
|
||||
// Never let a failed console write become an uncaught error and replace this
|
||||
// report with a stack trace - the console that cannot print the Chinese half
|
||||
// of these lines is exactly the one a user needs the English half from.
|
||||
// See scripts/lib/console-log.mjs and issue #152.
|
||||
const out = createLineWriter(process.stderr);
|
||||
|
||||
out("");
|
||||
out("============================================================");
|
||||
if (mismatch) {
|
||||
out(" [ERROR] 原生模块与当前 Node 版本不匹配");
|
||||
out(" Native modules do not match this Node version");
|
||||
} else {
|
||||
out(" [ERROR] 原生模块无法加载 / native module failed to load");
|
||||
}
|
||||
out("============================================================");
|
||||
out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`);
|
||||
if (stamp && stamp.abi) {
|
||||
out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`);
|
||||
out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`);
|
||||
}
|
||||
out("");
|
||||
for (const b of broken) {
|
||||
if (b.abiMismatch) {
|
||||
out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`);
|
||||
out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`);
|
||||
out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`);
|
||||
} else {
|
||||
out(` x ${b.spec}: ${b.error}`);
|
||||
}
|
||||
}
|
||||
out("");
|
||||
out(" 怎么修 / How to fix:");
|
||||
out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`);
|
||||
out(" (它会自动为当前 Node 版本重新安装原生模块)");
|
||||
out(" (setup now repairs the native modules for whatever Node you run)");
|
||||
out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version");
|
||||
out(" you installed with, then start again.");
|
||||
out("============================================================");
|
||||
out("");
|
||||
}
|
||||
|
||||
if (broken.length > 0) {
|
||||
report();
|
||||
// exitCode rather than exit(): lets the message flush when stderr is piped.
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================
|
||||
|
||||
# --- Stage 1: Build backend + frontend ---
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
# Install build tools for native modules (opus, better-sqlite3)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -30,7 +30,7 @@ RUN npm run build
|
||||
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
||||
|
||||
# --- Stage 2: Production image ---
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
|
||||
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
||||
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
||||
@@ -46,6 +46,13 @@ COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/web/dist ./web/dist
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
# package.json declares a `prestart` preflight, so `npm start` inside the
|
||||
# container needs this file. The image's own CMD calls node directly and never
|
||||
# goes through npm, but an interactive `docker exec ... npm start` would
|
||||
# otherwise die on a missing script rather than starting the bot.
|
||||
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
|
||||
# ...and the module it imports for crash-proof logging.
|
||||
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
|
||||
|
||||
# Data directory for database, cookies, logs
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
+685
-95
@@ -1,161 +1,751 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
|
||||
* Called by setup.bat after npm install --ignore-scripts.
|
||||
* Verify / download / repair the native binaries used by TSMusicBot
|
||||
* (ffmpeg-static + @discordjs/opus + better-sqlite3), preferring the
|
||||
* npmmirror CDN so China users never have to reach GitHub.
|
||||
*
|
||||
* Called by setup.bat / setup.sh after `npm install --ignore-scripts`.
|
||||
*
|
||||
* WHY THIS IS NOT JUST A DOWNLOADER
|
||||
* ---------------------------------
|
||||
* A compiled addon only loads into the exact Node ABI it was built for
|
||||
* (process.versions.modules: Node 20 = 115, Node 22 = 127, Node 24 = 137).
|
||||
* better-sqlite3 stores its addon at an ABI-agnostic path
|
||||
* (build/Release/better_sqlite3.node), so a "file exists and is big enough"
|
||||
* check happily keeps a binary built for a *different* Node major around and
|
||||
* the bot then dies with `NODE_MODULE_VERSION 137 ... requires 127`.
|
||||
* So we validate by actually LOADING each package — in a short-lived child
|
||||
* process, because on Windows a loaded .node stays mapped and the OS then
|
||||
* refuses to delete or overwrite it.
|
||||
*
|
||||
* Every repair is staged and swapped in atomically: if a download fails we put
|
||||
* the previous file back, so a failed run can never leave the install in a
|
||||
* worse state than it started.
|
||||
*
|
||||
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
|
||||
* Env: TSMB_BINARY_LOG_STDOUT=1 also echo progress to stdout
|
||||
* (setup.bat uses this to show progress live on stderr while stdout
|
||||
* is redirected into setup.log)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { createGunzip } from "node:zlib";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { get } from "node:https";
|
||||
import { Readable } from "node:stream";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createLineWriter } from "./lib/console-log.mjs";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const NODE_MODULES = join(ROOT, "node_modules");
|
||||
const BACKUP_DIR = join(NODE_MODULES, ".tsmusicbot-backup");
|
||||
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
|
||||
|
||||
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
|
||||
const PLATFORM = process.platform;
|
||||
const ARCH = process.arch;
|
||||
const NODE_ABI = process.versions.modules;
|
||||
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
||||
/** The newest Node major this project is regularly tested against, and the one
|
||||
* every required addon currently ships a prebuild for. Keep in sync with
|
||||
* TESTED_NODE_MAJOR in scripts/setup.bat. */
|
||||
const TESTED_NODE_MAJOR = 22;
|
||||
|
||||
function download(url) {
|
||||
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
|
||||
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
|
||||
const REQUIRED = new Set(["@discordjs/opus", "better-sqlite3"]);
|
||||
|
||||
/** ffmpeg-static ships ~40-90 MB depending on platform; anything under this is
|
||||
* certainly a truncated download, not a real build. */
|
||||
const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Progress goes to stderr so setup.bat can show it live while stdout is being
|
||||
// appended to setup.log. TSMB_BINARY_LOG_STDOUT=1 mirrors it into stdout so the
|
||||
// log keeps the full transcript too.
|
||||
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
|
||||
|
||||
// Both writers swallow a failed write instead of letting it become an uncaught
|
||||
// 'error' event: a console that cannot print the Chinese half of a line (issue
|
||||
// #152) must not be able to abort a whole setup run. The two streams degrade
|
||||
// independently, so setup.log keeps the full bilingual transcript either way.
|
||||
const writeErr = createLineWriter(process.stderr);
|
||||
const writeOut = createLineWriter(process.stdout);
|
||||
|
||||
function log(msg) {
|
||||
const line = msg === "" ? "" : ` [binary] ${msg}`;
|
||||
writeErr(line);
|
||||
if (ECHO_STDOUT) writeOut(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sizeOf(filePath) {
|
||||
try {
|
||||
return statSync(filePath).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function humanSize(filePath) {
|
||||
const bytes = sizeOf(filePath);
|
||||
if (!bytes) return "unknown size";
|
||||
return bytes >= 1024 * 1024
|
||||
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
function ensureExecutable(filePath) {
|
||||
if (PLATFORM === "win32") return;
|
||||
try {
|
||||
chmodSync(filePath, 0o755);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the version actually present in node_modules (never hardcode it: the
|
||||
* lockfile can be far ahead of whatever version this script was written for,
|
||||
* and a wrong version means a 404 on the CDN). */
|
||||
function readInstalledVersion(spec) {
|
||||
try {
|
||||
const pkgJson = join(NODE_MODULES, ...spec.split("/"), "package.json");
|
||||
const version = JSON.parse(readFileSync(pkgJson, "utf8")).version;
|
||||
return typeof version === "string" && version ? version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeError(text) {
|
||||
const lines = String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
|
||||
return (interesting || lines[0] || "unknown error").slice(0, 300);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function download(url, redirects = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = get(url, { timeout: 120000 }, (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
|
||||
const { statusCode, headers } = res;
|
||||
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||
res.resume();
|
||||
if (redirects >= 5) {
|
||||
reject(new Error(`too many redirects: ${url}`));
|
||||
return;
|
||||
}
|
||||
resolve(download(new URL(headers.location, url).toString(), redirects + 1));
|
||||
return;
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${statusCode}: ${url}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error(`timeout: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(` [binary] ${msg}`);
|
||||
let tarModule = null;
|
||||
|
||||
/** `tar` is not a declared dependency — it only resolves transitively through
|
||||
* prebuild-install / @discordjs/node-pre-gyp. Fail with a sentence a user can
|
||||
* act on instead of a raw MODULE_NOT_FOUND stack. */
|
||||
function loadTar() {
|
||||
if (tarModule) return tarModule;
|
||||
try {
|
||||
tarModule = createRequire(import.meta.url)("tar");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"'tar' module not available / 找不到 tar 模块 — run `npm install tar` in the project root and retry",
|
||||
);
|
||||
}
|
||||
return tarModule;
|
||||
}
|
||||
|
||||
function isValidSize(filePath, minBytes) {
|
||||
try { return statSync(filePath).size >= minBytes; } catch { return false; }
|
||||
async function extractTarGz(buf, cwd) {
|
||||
const tar = loadTar();
|
||||
const tmpFile = join(tmpdir(), `tsmb-${process.pid}-${Date.now()}.tar.gz`);
|
||||
writeFileSync(tmpFile, buf);
|
||||
try {
|
||||
await tar.extract({ cwd, file: tmpFile });
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tmpFile, { force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFfmpeg() {
|
||||
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
|
||||
// ---------------------------------------------------------------------------
|
||||
// load probe (the whole point of this rewrite)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The snippet that actually forces each package's addon to be dlopen()ed.
|
||||
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor
|
||||
* (lib/database.js: `DEFAULT_ADDON || (DEFAULT_ADDON = require('bindings')(...))`),
|
||||
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI binary.
|
||||
* Opening an in-memory database is the cheapest way to really load it.
|
||||
*/
|
||||
const PROBE_EXPR = {
|
||||
"@discordjs/opus": "require('@discordjs/opus')",
|
||||
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to load a package in a throwaway child process.
|
||||
* Child process on purpose: loading an addon here would keep the DLL mapped and
|
||||
* Windows would then refuse to rename/delete the file we are about to replace.
|
||||
*/
|
||||
function probeRequire(spec) {
|
||||
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
|
||||
try {
|
||||
execFileSync(process.execPath, ["-e", expr], {
|
||||
cwd: ROOT,
|
||||
stdio: "pipe",
|
||||
timeout: 120000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
|
||||
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
|
||||
// requires NODE_MODULE_VERSION 127..." -> first number is what it was built for.
|
||||
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
|
||||
return {
|
||||
ok: false,
|
||||
abiMismatch: abis.length >= 2,
|
||||
compiledAbi: abis.length >= 2 ? abis[0] : null,
|
||||
error: summarizeError(text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function describeProbe(probe) {
|
||||
if (probe.abiMismatch) {
|
||||
return `built for Node ABI ${probe.compiledAbi}, but this Node needs ABI ${NODE_ABI}`;
|
||||
}
|
||||
return probe.error;
|
||||
}
|
||||
|
||||
function probeFfmpegBinary(bin) {
|
||||
try {
|
||||
const out = execFileSync(bin, ["-version"], {
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
}).toString();
|
||||
return { ok: true, version: (out.split(/\r?\n/)[0] || "").slice(0, 60) };
|
||||
} catch (err) {
|
||||
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
|
||||
return { ok: false, error: summarizeError(text) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// atomic swap helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let stashCounter = 0;
|
||||
|
||||
/**
|
||||
* Move `target` (file or directory) out of the way into node_modules/.tsmusicbot-backup.
|
||||
* Same volume as node_modules, so the rename is atomic, and outside the package's
|
||||
* build/ tree so that `npm rebuild` / `node-gyp clean` cannot wipe the backup.
|
||||
* Returns { commit, restore } — call exactly one of them.
|
||||
*/
|
||||
/** Windows likes to hold a brief lock on a freshly written .node (antivirus,
|
||||
* indexer), and rmSync does not retry by default. */
|
||||
const RM_OPTS = { recursive: true, force: true, maxRetries: 5, retryDelay: 150 };
|
||||
|
||||
function stash(target) {
|
||||
if (!existsSync(target)) {
|
||||
return { commit() {}, restore() {} };
|
||||
}
|
||||
mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
const backup = join(BACKUP_DIR, `${basename(target)}.${process.pid}.${stashCounter++}.bak`);
|
||||
rmSync(backup, RM_OPTS);
|
||||
renameSync(target, backup);
|
||||
// The backup filename alone cannot say where the artifact came from, and a
|
||||
// run that is killed (Ctrl+C during a slow download) never reaches commit or
|
||||
// restore. Record the target so the next run can put it back — see
|
||||
// recoverOrphanedBackups().
|
||||
const manifest = `${backup}.json`;
|
||||
try {
|
||||
writeFileSync(manifest, `${JSON.stringify({ target })}\n`);
|
||||
} catch {
|
||||
/* recovery is best-effort; the swap itself still works */
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const dropManifest = () => {
|
||||
try {
|
||||
rmSync(manifest, RM_OPTS);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return {
|
||||
commit() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
rmSync(backup, RM_OPTS);
|
||||
} catch {
|
||||
/* leftover backup is harmless */
|
||||
}
|
||||
dropManifest();
|
||||
},
|
||||
restore() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
rmSync(target, RM_OPTS);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
renameSync(backup, target);
|
||||
dropManifest();
|
||||
log(`restored the previous ${basename(target)} — nothing was made worse`);
|
||||
} catch (err) {
|
||||
// Leave the backup AND its manifest in place: recoverOrphanedBackups()
|
||||
// on the next run is the second chance.
|
||||
log(`WARN: could not restore ${target} from ${backup}: ${err.message}`);
|
||||
log(`WARN: the previous file is still at ${backup} — the next run will try again`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back anything a previous run stashed but never restored — a run killed
|
||||
* mid-download, or one whose restore() itself failed. Only acts when the target
|
||||
* is currently absent, so it can never clobber a good binary.
|
||||
*/
|
||||
function recoverOrphanedBackups() {
|
||||
if (!existsSync(BACKUP_DIR)) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(BACKUP_DIR);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
const manifest = join(BACKUP_DIR, entry);
|
||||
const backup = manifest.slice(0, -".json".length);
|
||||
try {
|
||||
const { target } = JSON.parse(readFileSync(manifest, "utf8"));
|
||||
if (!target || !existsSync(backup)) {
|
||||
rmSync(manifest, RM_OPTS);
|
||||
continue;
|
||||
}
|
||||
if (existsSync(target)) continue; // a good file is already there — leave it alone
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
renameSync(backup, target);
|
||||
rmSync(manifest, RM_OPTS);
|
||||
log(`recovered ${basename(target)} left behind by an interrupted run`);
|
||||
} catch (err) {
|
||||
log(`WARN: could not process leftover backup ${entry}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupBackupDir() {
|
||||
try {
|
||||
if (existsSync(BACKUP_DIR) && readdirSync(BACKUP_DIR).length === 0) {
|
||||
rmSync(BACKUP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// per-module results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** status: "ok" | "repaired" | "failed" | "missing" */
|
||||
function makeResult(name, status, detail) {
|
||||
return { name, required: REQUIRED.has(name), status, detail };
|
||||
}
|
||||
|
||||
function buildFromSource(command) {
|
||||
// stdout -> inherited (setup.bat sends it to the log), stderr -> inherited so
|
||||
// compiler progress stays visible; npm's own output is far too noisy to buffer.
|
||||
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
|
||||
}
|
||||
|
||||
/**
|
||||
* A 404 from the CDN is not a mirror outage: it means this exact package
|
||||
* version publishes no prebuilt binary for the running Node ABI at all.
|
||||
* @discordjs/opus 0.10.0 has no build for Node 24 (ABI 137), so a user on that
|
||||
* major lands in the source-build fallback below and is told to install Python
|
||||
* and a C++ toolchain. Switching Node major is the far cheaper fix, and nothing
|
||||
* else in this output points at it. (better-sqlite3 dropping its Node 20 / ABI
|
||||
* 115 builds in 12.10.0 is why Node 20 is no longer accepted at all.)
|
||||
* See issue #152.
|
||||
*/
|
||||
function explainMissingPrebuild(name, version, err) {
|
||||
if (!/HTTP 404/.test(err.message)) return;
|
||||
log(`${name}: ${name}@${version} ships no prebuilt binary for Node ${NODE_MAJOR} (ABI ${NODE_ABI})`);
|
||||
log(
|
||||
`${name}: Node ${TESTED_NODE_MAJOR} LTS has one — switching Node is usually much quicker than ` +
|
||||
`setting up a compiler (换用 Node ${TESTED_NODE_MAJOR} LTS 通常比装编译环境快得多)`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolsHint() {
|
||||
log("Install build tools first:");
|
||||
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
|
||||
log(" Ubuntu/Debian: sudo apt install build-essential python3");
|
||||
log(" CentOS/RHEL: sudo yum groupinstall 'Development Tools'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ffmpeg-static (OPTIONAL — a system ffmpeg is a documented fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureFfmpeg() {
|
||||
const name = "ffmpeg-static";
|
||||
const ffDir = join(NODE_MODULES, name);
|
||||
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
const ffDest = join(ffDir, ffName);
|
||||
|
||||
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
|
||||
if (existsSync(ffDest)) {
|
||||
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
|
||||
log("ffmpeg already exists, skipping");
|
||||
return true;
|
||||
if (!existsSync(ffDir)) {
|
||||
log(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`);
|
||||
return makeResult(name, "missing", "package not installed");
|
||||
}
|
||||
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
|
||||
|
||||
if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) {
|
||||
ensureExecutable(ffDest);
|
||||
const probe = probeFfmpegBinary(ffDest);
|
||||
if (probe.ok) {
|
||||
log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`);
|
||||
return makeResult(name, "ok", humanSize(ffDest));
|
||||
}
|
||||
// Deliberately NOT re-downloading here: ffmpeg is a plain executable with no
|
||||
// ABI to mismatch, and forcing an ~80 MB re-download because `-version`
|
||||
// could not be spawned would hurt exactly the slow-network users this
|
||||
// script exists for.
|
||||
log(`${name}: present (${humanSize(ffDest)}) but could not be executed: ${probe.error}`);
|
||||
return makeResult(name, "ok", "present, not verified");
|
||||
}
|
||||
|
||||
if (existsSync(ffDest)) {
|
||||
log(`${name}: existing ffmpeg looks truncated (${humanSize(ffDest)}), re-downloading...`);
|
||||
} else {
|
||||
log(`${name}: ffmpeg binary missing, downloading...`);
|
||||
}
|
||||
|
||||
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
|
||||
log("Downloading ffmpeg...");
|
||||
const buf = await download(url);
|
||||
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
|
||||
try { execSync(`chmod +x "${ffDest}"`); } catch {}
|
||||
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
|
||||
log(`ffmpeg OK (${size} MB)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadOpus() {
|
||||
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
|
||||
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
|
||||
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
|
||||
|
||||
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
|
||||
if (existsSync(opusDest)) {
|
||||
if (isValidSize(opusDest, 100 * 1024)) {
|
||||
log("@discordjs/opus already exists, skipping");
|
||||
return true;
|
||||
}
|
||||
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
|
||||
}
|
||||
|
||||
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
|
||||
log("Downloading @discordjs/opus...");
|
||||
const backup = stash(ffDest);
|
||||
const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`;
|
||||
try {
|
||||
log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`);
|
||||
const buf = await download(url);
|
||||
mkdirSync(dirname(opusDest), { recursive: true });
|
||||
const require = createRequire(import.meta.url);
|
||||
const tar = require("tar");
|
||||
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
|
||||
writeFileSync(tmpFile, buf);
|
||||
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
|
||||
log("@discordjs/opus OK");
|
||||
return true;
|
||||
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(tmpDest));
|
||||
ensureExecutable(tmpDest);
|
||||
if (sizeOf(tmpDest) < FFMPEG_MIN_BYTES) {
|
||||
throw new Error(`downloaded ffmpeg is only ${humanSize(tmpDest)} — truncated`);
|
||||
}
|
||||
renameSync(tmpDest, ffDest); // atomic swap, same directory
|
||||
backup.commit();
|
||||
log(`${name}: OK (${humanSize(ffDest)})`);
|
||||
return makeResult(name, "repaired", humanSize(ffDest));
|
||||
} catch (err) {
|
||||
log(`CDN download failed (${err.message}), trying to build from source...`);
|
||||
try {
|
||||
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
|
||||
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
|
||||
log("@discordjs/opus built from source OK");
|
||||
return true;
|
||||
rmSync(tmpDest, { force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
backup.restore();
|
||||
log(`${name}: download failed — ${err.message}`);
|
||||
log(`${name}: not fatal — install ffmpeg system-wide and put it on PATH instead`);
|
||||
return makeResult(name, "failed", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// @discordjs/opus (REQUIRED)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureOpus() {
|
||||
const name = "@discordjs/opus";
|
||||
const pkgDir = join(NODE_MODULES, "@discordjs", "opus");
|
||||
const prebuildRoot = join(pkgDir, "prebuild");
|
||||
// node-pre-gyp resolves this directory from the *running* Node's ABI, so a
|
||||
// stale build for another ABI simply sits at another path and is ignored.
|
||||
const prebuildDirName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
|
||||
const destDir = join(prebuildRoot, prebuildDirName);
|
||||
|
||||
if (!existsSync(pkgDir)) {
|
||||
log(`${name}: package not installed — run 'npm install' first`);
|
||||
return makeResult(name, "missing", "package not installed");
|
||||
}
|
||||
|
||||
const before = probeRequire(name);
|
||||
if (before.ok) {
|
||||
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
|
||||
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
|
||||
}
|
||||
log(`${name}: unusable — ${describeProbe(before)}`);
|
||||
log(`${name}: installing a build for ABI ${NODE_ABI}...`);
|
||||
|
||||
const version = readInstalledVersion(name) || "0.10.0";
|
||||
const url =
|
||||
`${CDN}/@discordjs/opus/v${version}/opus-v${version}` +
|
||||
`-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
|
||||
|
||||
const backup = stash(destDir);
|
||||
let staging = null;
|
||||
try {
|
||||
try {
|
||||
log(`${name}: GET ${url}`);
|
||||
const buf = await download(url);
|
||||
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
|
||||
await extractTarGz(buf, staging);
|
||||
const staged = join(staging, prebuildDirName);
|
||||
if (!existsSync(join(staged, "opus.node"))) {
|
||||
throw new Error(`tarball did not contain ${prebuildDirName}/opus.node`);
|
||||
}
|
||||
mkdirSync(prebuildRoot, { recursive: true });
|
||||
rmSync(destDir, { recursive: true, force: true });
|
||||
renameSync(staged, destDir); // atomic swap, same volume
|
||||
log(`${name}: prebuilt binary installed`);
|
||||
} catch (cdnErr) {
|
||||
log(`${name}: CDN install failed (${cdnErr.message})`);
|
||||
explainMissingPrebuild(name, version, cdnErr);
|
||||
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
|
||||
buildFromSource(`npm rebuild ${name}`);
|
||||
}
|
||||
|
||||
const after = probeRequire(name);
|
||||
if (!after.ok) throw new Error(describeProbe(after));
|
||||
backup.commit();
|
||||
log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI})`);
|
||||
return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
|
||||
} catch (err) {
|
||||
backup.restore();
|
||||
log(`${name}: FAILED — ${err.message}`);
|
||||
buildToolsHint();
|
||||
return makeResult(name, "failed", err.message);
|
||||
} finally {
|
||||
if (staging) {
|
||||
try {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log("Source build completed but .node file not found");
|
||||
return false;
|
||||
} catch (buildErr) {
|
||||
log(`Source build failed: ${buildErr.message}`);
|
||||
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
|
||||
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBetterSqlite3() {
|
||||
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
|
||||
// ---------------------------------------------------------------------------
|
||||
// better-sqlite3 (REQUIRED) — the module the ABI bug actually bites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureBetterSqlite3() {
|
||||
const name = "better-sqlite3";
|
||||
const pkgDir = join(NODE_MODULES, name);
|
||||
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
|
||||
|
||||
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
|
||||
if (existsSync(dest)) {
|
||||
if (isValidSize(dest, 500 * 1024)) {
|
||||
log("better-sqlite3 already exists, skipping");
|
||||
return true;
|
||||
}
|
||||
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
|
||||
if (!existsSync(pkgDir)) {
|
||||
log(`${name}: package not installed — run 'npm install' first`);
|
||||
return makeResult(name, "missing", "package not installed");
|
||||
}
|
||||
|
||||
const version = "12.8.0";
|
||||
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
|
||||
log("Downloading better-sqlite3...");
|
||||
const before = probeRequire(name);
|
||||
if (before.ok) {
|
||||
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
|
||||
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
|
||||
}
|
||||
// This is the case the old size check could not see: the file is there, it is
|
||||
// ~1.9 MB, and it is completely useless because it targets another ABI.
|
||||
log(`${name}: unusable — ${describeProbe(before)}`);
|
||||
log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`);
|
||||
|
||||
const version = readInstalledVersion(name) || "12.11.1";
|
||||
const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
|
||||
|
||||
const backup = stash(dest);
|
||||
let staging = null;
|
||||
try {
|
||||
try {
|
||||
log(`${name}: GET ${url}`);
|
||||
const buf = await download(url);
|
||||
const require = createRequire(import.meta.url);
|
||||
const tar = require("tar");
|
||||
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
|
||||
writeFileSync(tmpFile, buf);
|
||||
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
|
||||
await extractTarGz(buf, staging);
|
||||
const staged = join(staging, "build", "Release", "better_sqlite3.node");
|
||||
if (!existsSync(staged)) {
|
||||
throw new Error("tarball did not contain build/Release/better_sqlite3.node");
|
||||
}
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
await tar.extract({ cwd: pkgDir, file: tmpFile });
|
||||
if (existsSync(dest)) {
|
||||
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
|
||||
return true;
|
||||
rmSync(dest, { force: true });
|
||||
renameSync(staged, dest); // atomic swap, same volume
|
||||
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
|
||||
} catch (cdnErr) {
|
||||
log(`${name}: CDN install failed (${cdnErr.message})`);
|
||||
explainMissingPrebuild(name, version, cdnErr);
|
||||
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
|
||||
buildFromSource(`npm rebuild ${name} --build-from-source`);
|
||||
}
|
||||
log("better-sqlite3 extracted but .node file not found at expected path");
|
||||
return false;
|
||||
|
||||
const after = probeRequire(name);
|
||||
if (!after.ok) throw new Error(describeProbe(after));
|
||||
backup.commit();
|
||||
log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI}, ${humanSize(dest)})`);
|
||||
return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
|
||||
} catch (err) {
|
||||
backup.restore();
|
||||
log(`${name}: FAILED — ${err.message}`);
|
||||
buildToolsHint();
|
||||
return makeResult(name, "failed", err.message);
|
||||
} finally {
|
||||
if (staging) {
|
||||
try {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stamp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Record which ABI this install was built for. Lives inside node_modules so it
|
||||
* dies together with the thing it describes. check-native.mjs reads it. */
|
||||
function writeStamp(results) {
|
||||
if (!existsSync(NODE_MODULES)) return;
|
||||
const stamp = {
|
||||
abi: NODE_ABI,
|
||||
nodeVersion: process.version,
|
||||
platform: PLATFORM,
|
||||
arch: ARCH,
|
||||
updatedAt: new Date().toISOString(),
|
||||
modules: Object.fromEntries(results.map((r) => [r.name, r.status])),
|
||||
};
|
||||
try {
|
||||
writeFileSync(STAMP_FILE, `${JSON.stringify(stamp, null, 2)}\n`);
|
||||
log(`ABI stamp written: node_modules/.tsmusicbot-abi (Node ${process.version}, ABI ${NODE_ABI})`);
|
||||
} catch (err) {
|
||||
log(`WARN: could not write ABI stamp: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STEPS = [
|
||||
["ffmpeg-static", ensureFfmpeg],
|
||||
["@discordjs/opus", ensureOpus],
|
||||
["better-sqlite3", ensureBetterSqlite3],
|
||||
];
|
||||
|
||||
try {
|
||||
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
|
||||
if (results.some(Boolean)) {
|
||||
console.log(" [binary] All downloads complete");
|
||||
log(`Node ${process.version} (ABI ${NODE_ABI}), ${PLATFORM}-${ARCH}, CDN ${CDN}`);
|
||||
|
||||
recoverOrphanedBackups();
|
||||
|
||||
// STRICTLY SEQUENTIAL, and it has to stay that way. The source-build fallback
|
||||
// shells out through execSync, which parks the event loop for minutes; the
|
||||
// 120s timeout that download() arms is a socket-INACTIVITY timer sitting on
|
||||
// that same loop. Run these concurrently and the first module to fall back to
|
||||
// a source build kills every download still in flight — the connection is
|
||||
// healthy, the timer just never got a chance to be reset. That is not a rare
|
||||
// race: @discordjs/opus 0.10.0 has no prebuild for ABI 137, so on Node 24 that
|
||||
// module 404s within ~100ms and starts building while ffmpeg's ~80MB download
|
||||
// is still going. ffmpeg is optional,
|
||||
// so the spurious failure used to be swallowed as a WARN and setup still
|
||||
// reported success — leaving the user with no ffmpeg and no working playback.
|
||||
// Nothing here benefits from overlap anyway: every probe is execFileSync.
|
||||
const results = [];
|
||||
for (const [name, run] of STEPS) {
|
||||
try {
|
||||
results.push(await run());
|
||||
} catch (err) {
|
||||
results.push(makeResult(name, "failed", err?.message ?? String(err)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` [binary] ERROR: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cleanupBackupDir();
|
||||
|
||||
log("");
|
||||
log(`Summary — Node ${process.version} / ABI ${NODE_ABI} / ${PLATFORM}-${ARCH}:`);
|
||||
for (const r of results) {
|
||||
const tag =
|
||||
r.status === "ok"
|
||||
? "OK"
|
||||
: r.status === "repaired"
|
||||
? "REPAIRED"
|
||||
: r.required
|
||||
? "FAILED"
|
||||
: "WARN (optional)";
|
||||
log(` - ${r.name.padEnd(17)} ${tag}${r.detail ? ` ${r.detail}` : ""}`);
|
||||
}
|
||||
|
||||
const broken = results.filter(
|
||||
(r) => r.required && r.status !== "ok" && r.status !== "repaired",
|
||||
);
|
||||
// Only stamp a build that actually succeeded. The stamp says "node_modules is
|
||||
// built for ABI X"; writing it after a failed repair would have check-native
|
||||
// print a reassuring "built with ABI 137" right above its own "this module is
|
||||
// built for ABI 127" complaint.
|
||||
if (broken.length === 0) writeStamp(results);
|
||||
// process.exitCode rather than process.exit(): setup.bat redirects stdout to
|
||||
// setup.log, and process.exit() can drop output that has not flushed yet.
|
||||
if (broken.length > 0) {
|
||||
log("");
|
||||
log(`ERROR: required native module(s) unusable: ${broken.map((r) => r.name).join(", ")}`);
|
||||
log("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
log("All required native modules are ready.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`ERROR: ${e.stack || e.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
+63
-21
@@ -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
|
||||
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"
|
||||
|
||||
echo "[3/6] Installing dependencies..."
|
||||
cd "$PROJECT_DIR"
|
||||
npm install
|
||||
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
|
||||
(cd "$PROJECT_DIR/web" && npm install)
|
||||
fi
|
||||
|
||||
echo "[4/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,7 +141,7 @@ EOL
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ${SERVICE_NAME}
|
||||
sudo systemctl start ${SERVICE_NAME}
|
||||
sudo systemctl restart ${SERVICE_NAME}
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createLineWriter, toAsciiFallback } from "./console-log.mjs";
|
||||
|
||||
/** Stand-in for process.stderr: an EventEmitter with a write() we can steer. */
|
||||
function fakeStream() {
|
||||
const stream = new EventEmitter();
|
||||
stream.written = [];
|
||||
stream.throwOnWrite = false;
|
||||
stream.write = (chunk) => {
|
||||
if (stream.throwOnWrite) throw new Error("EBADF");
|
||||
stream.written.push(chunk);
|
||||
return true;
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
|
||||
describe("toAsciiFallback", () => {
|
||||
it("leaves ASCII lines exactly as they are", () => {
|
||||
const line = " [binary] better-sqlite3: OK (loads under v22.23.2, ABI 127)";
|
||||
expect(toAsciiFallback(line)).toBe(line);
|
||||
expect(toAsciiFallback("")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the English half of the line that crashed setup in #152", () => {
|
||||
expect(
|
||||
toAsciiFallback(
|
||||
" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)",
|
||||
),
|
||||
).toBe(" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB)");
|
||||
});
|
||||
|
||||
it("drops parentheses and separators left stranded by the removed text", () => {
|
||||
expect(
|
||||
toAsciiFallback(" [binary] better-sqlite3: falling back — 'npm rebuild' (可能需要几分钟)"),
|
||||
).toBe(" [binary] better-sqlite3: falling back - 'npm rebuild'");
|
||||
expect(
|
||||
toAsciiFallback(" Windows: npm install --global windows-build-tools (或安装 VS Build Tools)"),
|
||||
).toBe(" Windows: npm install --global windows-build-tools (VS Build Tools)");
|
||||
});
|
||||
|
||||
it("drops a Chinese-only line, which always has an English twin beside it", () => {
|
||||
expect(toAsciiFallback("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the indentation the summary is aligned on, and drops the dangling dash", () => {
|
||||
expect(toAsciiFallback(" - better-sqlite3 FAILED — 编译失败")).toBe(
|
||||
" - better-sqlite3 FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a separator that belongs to the English half", () => {
|
||||
expect(toAsciiFallback("Summary — Node v22.0.0 / ABI 127 / win32-x64:")).toBe(
|
||||
"Summary - Node v22.0.0 / ABI 127 / win32-x64:",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLineWriter", () => {
|
||||
it("appends a newline and reports the write", () => {
|
||||
const stream = fakeStream();
|
||||
expect(createLineWriter(stream)("hello")).toBe(true);
|
||||
expect(stream.written).toEqual(["hello\n"]);
|
||||
});
|
||||
|
||||
it("survives the EIO that killed setup: an 'error' event must not throw", () => {
|
||||
const stream = fakeStream();
|
||||
createLineWriter(stream);
|
||||
expect(stream.listenerCount("error")).toBe(1);
|
||||
expect(() => stream.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to ASCII once the console has refused a line", () => {
|
||||
const stream = fakeStream();
|
||||
const write = createLineWriter(stream);
|
||||
write(" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)");
|
||||
stream.emit("error", new Error("write EIO"));
|
||||
write(" [binary] GET https://cdn/opus.tar.gz (~1 MB, 这一步比较慢,请耐心等待)");
|
||||
expect(stream.written).toEqual([
|
||||
" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)\n",
|
||||
" [binary] GET https://cdn/opus.tar.gz (~1 MB)\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("goes quiet after a second failure rather than retrying a dead stream", () => {
|
||||
const stream = fakeStream();
|
||||
const write = createLineWriter(stream);
|
||||
stream.emit("error", new Error("write EIO"));
|
||||
stream.emit("error", new Error("write EPIPE"));
|
||||
expect(write("anything at all")).toBe(false);
|
||||
expect(stream.written).toEqual([]);
|
||||
});
|
||||
|
||||
it("degrades on a synchronous throw too, which never reaches the listener", () => {
|
||||
const stream = fakeStream();
|
||||
const write = createLineWriter(stream);
|
||||
stream.throwOnWrite = true;
|
||||
expect(write(" [binary] 下载中 downloading")).toBe(false);
|
||||
stream.throwOnWrite = false;
|
||||
write(" [binary] 下载中 downloading");
|
||||
expect(stream.written).toEqual([" [binary] downloading\n"]);
|
||||
});
|
||||
|
||||
it("degrades each stream on its own, so setup.log keeps the full transcript", () => {
|
||||
const console_ = fakeStream();
|
||||
const logFile = fakeStream();
|
||||
const writeConsole = createLineWriter(console_);
|
||||
const writeLog = createLineWriter(logFile);
|
||||
console_.emit("error", new Error("write EIO"));
|
||||
|
||||
const line = " [binary] ffmpeg-static: 下载完成 done";
|
||||
writeConsole(line);
|
||||
writeLog(line);
|
||||
|
||||
expect(console_.written).toEqual([" [binary] ffmpeg-static: done\n"]);
|
||||
expect(logFile.written).toEqual([`${line}\n`]);
|
||||
});
|
||||
|
||||
it("never installs a second listener for a stream that already has a writer", () => {
|
||||
const stream = fakeStream();
|
||||
createLineWriter(stream);
|
||||
createLineWriter(stream);
|
||||
expect(stream.listenerCount("error")).toBe(1);
|
||||
});
|
||||
|
||||
it("still guards a stream that is not an EventEmitter", () => {
|
||||
const stream = { write: vi.fn(() => { throw new Error("EBADF"); }) };
|
||||
const write = createLineWriter(stream);
|
||||
expect(() => write("line")).not.toThrow();
|
||||
expect(write("line")).toBe(false);
|
||||
});
|
||||
});
|
||||
+65
-11
@@ -10,8 +10,11 @@ title TSMusicBot Setup
|
||||
:: - 自动修复 PowerShell 环境变量
|
||||
:: ============================================================
|
||||
|
||||
set "SCRIPT_VERSION=2.1"
|
||||
set "MIN_NODE_MAJOR=20"
|
||||
set "SCRIPT_VERSION=2.2"
|
||||
set "MIN_NODE_MAJOR=22"
|
||||
:: Newest Node major this project is regularly tested against. Anything above
|
||||
:: still works, it just may have no prebuilt addons and fall back to a source build.
|
||||
set "TESTED_NODE_MAJOR=22"
|
||||
set "LOG_FILE=%~dp0..\setup.log"
|
||||
set "FAILED=0"
|
||||
|
||||
@@ -64,13 +67,40 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
|
||||
call :log "Node.js version: %NODE_VER%"
|
||||
echo [OK] Node.js found: %NODE_VER%
|
||||
|
||||
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
|
||||
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
|
||||
:: The supported floor is not just a major version, so let node decide.
|
||||
:: Node 20 was dropped: better-sqlite3 ships no prebuilt binary for its ABI
|
||||
:: (115) since 12.10.0, so every Node 20 install needed Python and a C++
|
||||
:: toolchain just to get off the ground (issue #152). The odd majors (21 /
|
||||
:: 23) are excluded by better-sqlite3 and vitest.
|
||||
:: Keep this in sync with "engines" in package.json.
|
||||
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
|
||||
if errorlevel 1 (
|
||||
call :error "Node.js %NODE_VER% is not supported. Use Node 22.12+ LTS or newer."
|
||||
echo Download: https://nodejs.org/ or https://nodejs.cn/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Not fatal: setup now rebuilds the native modules for whatever ABI you run,
|
||||
:: so newer Node majors work - they are just slower to install.
|
||||
:: NOTE: keep every line inside these parenthesised blocks pure ASCII.
|
||||
:: cmd.exe mis-tracks its file offset when a block contains multi-byte UTF-8
|
||||
:: characters and starts eating the "echo " prefix of following lines.
|
||||
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
|
||||
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
|
||||
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 22.
|
||||
echo Newer Node majors may have no prebuilt opus / better-sqlite3,
|
||||
echo so setup falls back to a source build - slower, needs C++ build tools.
|
||||
echo Recommended: Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
|
||||
echo This is only a warning; setup still builds the binaries for %NODE_VER%.
|
||||
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Native addons are tied to one Node ABI. If node_modules was built by a
|
||||
:: different Node major, step 4b below detects it and repairs it.
|
||||
call :log "Node ABI for this install: see node_modules\.tsmusicbot-abi after step 4b"
|
||||
|
||||
:: ============================================================
|
||||
:: Step 2: Check npm
|
||||
:: ============================================================
|
||||
@@ -143,16 +173,40 @@ echo [OK] Backend dependencies installed.
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 4b: Download native binaries from CDN
|
||||
:: Step 4b: Verify / download / repair native binaries (ABI aware)
|
||||
:: ============================================================
|
||||
call :step "4b/7" "Downloading native binaries"
|
||||
call :step "4b/7" "Checking native binaries"
|
||||
|
||||
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
|
||||
) else (
|
||||
echo [OK] Native binaries installed.
|
||||
echo Verifying native modules for %NODE_VER% and downloading whatever is missing.
|
||||
echo Progress is shown below; the full transcript goes to the log file.
|
||||
echo.
|
||||
|
||||
:: The .mjs writes progress to stderr and - with TSMB_BINARY_LOG_STDOUT=1 - the
|
||||
:: same lines to stdout. Redirecting only stdout therefore keeps the log complete
|
||||
:: while the user still sees live progress instead of a frozen window.
|
||||
set "TSMB_BINARY_LOG_STDOUT=1"
|
||||
node scripts\download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%"
|
||||
set "BIN_RESULT=!errorlevel!"
|
||||
set "TSMB_BINARY_LOG_STDOUT="
|
||||
|
||||
:: ASCII only inside these blocks - see the note near the Node version check.
|
||||
if not "!BIN_RESULT!"=="0" (
|
||||
set "FAILED=1"
|
||||
call :error "A required native module is unusable - see the [binary] lines above."
|
||||
echo Required: @discordjs/opus and better-sqlite3.
|
||||
echo Full log: %LOG_FILE%
|
||||
)
|
||||
|
||||
if "!FAILED!"=="1" (
|
||||
echo.
|
||||
echo Setup aborted. Fix the problem above and run this script again.
|
||||
call :log "Setup aborted at step 4b"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [OK] Native binaries ready for %NODE_VER%.
|
||||
echo ABI recorded in node_modules\.tsmusicbot-abi
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
|
||||
+44
-7
@@ -21,12 +21,34 @@ echo ""
|
||||
|
||||
# ---- Check Node.js ----
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
|
||||
echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org"
|
||||
echo " or https://nodejs.cn/ (China mirror)."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Node.js $(node -v)"
|
||||
|
||||
# Newest Node major this project is regularly tested against. Anything above
|
||||
# still works, it just may have no prebuilt addons and fall back to a source build.
|
||||
TESTED_NODE_MAJOR=22
|
||||
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
||||
|
||||
# The floor is not just a major version, so let node decide. Node 20 was dropped:
|
||||
# better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
|
||||
# Node 20 install needed Python and a C++ toolchain just to get off the ground
|
||||
# (issue #152). The odd majors (21 / 23) are excluded by better-sqlite3 and vitest.
|
||||
# Keep in sync with package.json "engines".
|
||||
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
|
||||
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
|
||||
echo " https://nodejs.org/ | https://nodejs.cn/"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
|
||||
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 22)."
|
||||
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
|
||||
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
|
||||
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
|
||||
fi
|
||||
|
||||
if ! command -v npm &>/dev/null; then
|
||||
echo "[ERROR] npm not found."
|
||||
exit 1
|
||||
@@ -73,15 +95,30 @@ npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_F
|
||||
echo "[OK] Dependencies installed."
|
||||
echo ""
|
||||
|
||||
# ---- Step 2: Download native binaries from CDN ----
|
||||
echo "---- 2/5: Downloading native binaries ----"
|
||||
# ---- Step 2: Verify / download / repair native binaries (ABI aware) ----
|
||||
echo "---- 2/5: Checking native binaries ----"
|
||||
echo ""
|
||||
|
||||
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
|
||||
echo "[OK] Native binaries installed."
|
||||
else
|
||||
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
|
||||
# The old `if node ... | tee ...` only printed a [WARN] and carried on, so a
|
||||
# broken native module still produced a "Setup Complete!" banner. It also read
|
||||
# the *pipeline's* status: `set -o pipefail` above happens to surface node's
|
||||
# failure, but a failing `tee` (unwritable log) was indistinguishable from a
|
||||
# failing node. PIPESTATUS[0] is exactly node's own exit code, nothing else.
|
||||
set +e
|
||||
node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"
|
||||
BIN_STATUS=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$BIN_STATUS" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "[ERROR] A required native module (@discordjs/opus / better-sqlite3) is unusable."
|
||||
echo " 必需的原生模块不可用,安装中止。原因见上面的 [binary] 输出。"
|
||||
echo " Log: $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# ffmpeg-static failures are only a WARN inside the script above (a system
|
||||
# ffmpeg on PATH is a supported fallback), so reaching here means we are good.
|
||||
echo "[OK] Native binaries ready for $(node -v)."
|
||||
echo ""
|
||||
|
||||
# ---- Step 3: Install web panel dependencies ----
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test for issue #51 — run AFTER you start the bot from temp/preview-merge
|
||||
# (or from main once both PRs are merged).
|
||||
#
|
||||
# Usage: ./scripts/smoke_issue51.sh [HOST]
|
||||
# Default HOST is http://127.0.0.1:3000
|
||||
|
||||
set -e
|
||||
HOST="${1:-http://127.0.0.1:3000}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
note() { echo -e "\n=== $* ==="; }
|
||||
ok() { echo " [PASS] $*"; PASS=$((PASS+1)); }
|
||||
bad() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ---- Album search ----------------------------------------------------------
|
||||
note "1. /api/music/search/all returns {songs,albums,playlists}"
|
||||
|
||||
RES=$(curl.exe -s "$HOST/api/music/search/all?q=%E5%91%A8%E6%9D%B0%E4%BC%A6") # 周杰伦
|
||||
KEYS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(','.join(sorted(d.keys())))")
|
||||
if [ "$KEYS" = "albums,playlists,songs" ]; then ok "keys = $KEYS"; else bad "keys = $KEYS (expected albums,playlists,songs)"; fi
|
||||
|
||||
NA=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('albums',[])))")
|
||||
NS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))")
|
||||
NP=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('playlists',[])))")
|
||||
echo " songs=$NS, albums=$NA, playlists=$NP"
|
||||
if [ "$NA" -gt 0 ]; then ok "albums populated"; else bad "albums empty (expected >0 for 周杰伦)"; fi
|
||||
if [ "$NS" -gt 0 ]; then ok "songs populated"; fi
|
||||
|
||||
# ---- Album detail playback path -------------------------------------------
|
||||
note "2. /api/music/album/:id returns songs"
|
||||
|
||||
if [ "$NA" -gt 0 ]; then
|
||||
ALBUM_ID=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['id'])")
|
||||
PLATFORM=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['platform'])")
|
||||
echo " testing album id=$ALBUM_ID platform=$PLATFORM"
|
||||
ASONGS=$(curl.exe -s "$HOST/api/music/album/$ALBUM_ID?platform=$PLATFORM" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))" 2>/dev/null || echo 0)
|
||||
if [ "$ASONGS" -gt 0 ]; then ok "album returned $ASONGS songs"; else bad "album endpoint returned 0 songs"; fi
|
||||
else
|
||||
echo " (skipped — no albums to test)"
|
||||
fi
|
||||
|
||||
# ---- Avatar API ------------------------------------------------------------
|
||||
note "3. avatar GET 404 on bot with no avatar"
|
||||
|
||||
BOT_ID=$(curl.exe -s "$HOST/api/bot" | python3 -c "import json,sys;d=json.load(sys.stdin);bots=d.get('bots',[]);print(bots[0]['id'] if bots else '')")
|
||||
if [ -z "$BOT_ID" ]; then bad "no bot found — create a bot first"; exit 1; fi
|
||||
echo " using bot $BOT_ID"
|
||||
|
||||
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
if [ "$CODE" = "404" ] || [ "$CODE" = "200" ]; then ok "GET initial state = $CODE"; else bad "unexpected GET status $CODE"; fi
|
||||
|
||||
note "4. avatar PUT 200 + GET 200 round-trip"
|
||||
# 1×1 transparent PNG (67 bytes)
|
||||
TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
PUT_RES=$(curl.exe -s -X PUT "$HOST/api/bot/$BOT_ID/avatar" -H "Content-Type: application/json" \
|
||||
-d "{\"dataUrl\":\"data:image/png;base64,$TINY_PNG_B64\"}")
|
||||
echo " PUT response: $PUT_RES"
|
||||
GOT_PATH=$(echo "$PUT_RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('path',''))" 2>/dev/null || echo "")
|
||||
if [ "$GOT_PATH" = "$BOT_ID.png" ]; then ok "PUT returned path=$GOT_PATH"; else bad "PUT path = $GOT_PATH (expected $BOT_ID.png)"; fi
|
||||
|
||||
curl.exe -s -o /tmp/avatar_check.png -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
SIZE=$(wc -c < /tmp/avatar_check.png)
|
||||
if [ "$CODE" = "200" ] && [ "$SIZE" -gt 60 ]; then ok "GET returned 200, $SIZE bytes"; else bad "GET status=$CODE size=$SIZE"; fi
|
||||
|
||||
note "5. avatar DELETE 204 + GET 404"
|
||||
curl.exe -s -X DELETE "$HOST/api/bot/$BOT_ID/avatar" -o /dev/null -w "%{http_code}" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
if [ "$CODE" = "204" ]; then ok "DELETE returned 204"; else bad "DELETE status = $CODE"; fi
|
||||
|
||||
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
if [ "$CODE" = "404" ]; then ok "GET after DELETE returned 404"; else bad "GET after DELETE = $CODE"; fi
|
||||
|
||||
note "6. avatar PUT rejects oversize (>200KB)"
|
||||
BIG_B64=$(node -e "console.log(Buffer.alloc(210*1024,7).toString('base64'))")
|
||||
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
|
||||
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/png;base64,$BIG_B64\"}" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
if [ "$CODE" = "413" ]; then ok "oversize rejected with 413"; else bad "oversize status = $CODE (expected 413)"; fi
|
||||
|
||||
note "7. avatar PUT rejects bad MIME (image/gif)"
|
||||
GIF_B64="R0lGODlhAQABAAAAACw=" # tiny gif
|
||||
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
|
||||
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/gif;base64,$GIF_B64\"}" > /tmp/code
|
||||
CODE=$(cat /tmp/code)
|
||||
if [ "$CODE" = "400" ]; then ok "bad MIME rejected with 400"; else bad "bad MIME status = $CODE (expected 400)"; fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo "SMOKE RESULT: $PASS passed, $FAIL failed"
|
||||
echo "============================================="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -29,6 +29,19 @@ if not exist "dist" (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Preflight: do the compiled native modules match THIS Node version?
|
||||
:: Switching Node majors after setup leaves node_modules built for the old ABI;
|
||||
:: without this check the bot dies mid-startup with a NODE_MODULE_VERSION stack.
|
||||
:: check-native.mjs prints the bilingual explanation itself; keep the lines in
|
||||
:: this block pure ASCII (cmd.exe garbles multi-byte text inside blocks).
|
||||
node scripts\check-native.mjs
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Please run scripts\setup.bat to rebuild the native modules.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
|
||||
where powershell >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
@@ -38,6 +51,9 @@ if errorlevel 1 (
|
||||
)
|
||||
|
||||
:: Start the application
|
||||
echo WebUI: http://localhost:3000
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
node dist/index.js
|
||||
|
||||
pause
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { collectFfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
|
||||
|
||||
async function finish(stream: PassThrough): Promise<void> {
|
||||
const ended = new Promise<void>((resolve) => stream.once("end", resolve));
|
||||
stream.end();
|
||||
await ended;
|
||||
}
|
||||
|
||||
describe("bounded FFmpeg diagnostics", () => {
|
||||
for (const [label, line, expected] of [
|
||||
["an apostrophe in the real FFmpeg URL error format", "Error opening input file http://127.0.0.1:9/audio?filename=artist's-song&api_key=quoted-secret.", "Error opening input file [URL omitted]"],
|
||||
["a space in URL userinfo", 'Error opening input file https://user:space secret@cdn.example/audio?token=space-secret.', "Error opening input file [URL omitted]"],
|
||||
["multiple URLs", 'Error opening inputs https://cdn.example/a?filename=artist\'s-song&key=first-secret and https://cdn.example/b?token=second-secret', "Error opening inputs [URL omitted]"],
|
||||
["quotes and spaces in a request target", "GET /audio?filename=artist's song&api_key=request-secret HTTP/1.1", "GET /audio?[query omitted]"],
|
||||
["an apostrophe in a Bearer value", "Token rejected Bearer prefix'quoted bearer-secret", "Token rejected Bearer [omitted]"],
|
||||
]) {
|
||||
it(`omits the entire sensitive suffix after ${label}`, async () => {
|
||||
const stream = new PassThrough();
|
||||
const diagnostics = collectFfmpegDiagnostics(stream);
|
||||
stream.write(line + "\n");
|
||||
await finish(stream);
|
||||
expect(diagnostics.getTail()).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const splitDelimiter of [false, true]) {
|
||||
it(`omits folded authentication values with ${splitDelimiter ? "chunk-split" : "intact"} CRLF`, async () => {
|
||||
const stream = new PassThrough();
|
||||
const diagnostics = collectFfmpegDiagnostics(stream);
|
||||
stream.write(`Authorization: Basic header-secret\r${splitDelimiter ? "" : "\n"}`);
|
||||
if (splitDelimiter) stream.write("\n");
|
||||
stream.write(" continuation-secret\r\nHTTP error 401\r\n");
|
||||
await finish(stream);
|
||||
expect(diagnostics.getTail()).toContain("HTTP error 401");
|
||||
expect(diagnostics.getTail()).not.toContain("header-secret");
|
||||
expect(diagnostics.getTail()).not.toContain("continuation-secret");
|
||||
});
|
||||
}
|
||||
|
||||
it("redacts URLs and headers split across arbitrary byte and UTF-8 boundaries", async () => {
|
||||
const stream = new PassThrough();
|
||||
const diagnostics = collectFfmpegDiagnostics(stream);
|
||||
const payload = Buffer.from("解码失败 https://user:user-secret@cdn.example/audio?token=query-secret#fragment-secret\rCookie: cookie-secret\nAuthorization: Bearer bearer-secret\nGET /audio?token=request-secret HTTP/1.1\nfinal error");
|
||||
for (const byte of payload) stream.write(Buffer.from([byte]));
|
||||
await finish(stream);
|
||||
expect(diagnostics.getTail()).toContain("解码失败");
|
||||
expect(diagnostics.getTail()).toContain("final error");
|
||||
for (const secret of ["user-secret", "query-secret", "fragment-secret", "cookie-secret", "bearer-secret", "request-secret"]) {
|
||||
expect(diagnostics.getTail()).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits oversized raw lines without retaining an unsafe credential suffix", async () => {
|
||||
const stream = new PassThrough();
|
||||
const diagnostics = collectFfmpegDiagnostics(stream);
|
||||
stream.write("Cookie: " + "x".repeat(100000));
|
||||
stream.write("oversized-secret\r\n folded-oversized-secret\r\nHTTP error 403\n");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(diagnostics.getTail()).toContain("HTTP error 403");
|
||||
expect(diagnostics.getTail()).not.toContain("oversized-secret");
|
||||
stream.write("decoder warning\n".repeat(10000));
|
||||
stream.write("last useful error\n");
|
||||
await finish(stream);
|
||||
expect(diagnostics.getTail().length).toBeLessThanOrEqual(4096);
|
||||
expect(diagnostics.getTail()).toContain("last useful error");
|
||||
expect(diagnostics.getTail()).not.toContain("oversized-secret");
|
||||
});
|
||||
|
||||
it("withholds an incomplete credential line until it can be safely sanitized", async () => {
|
||||
const stream = new PassThrough();
|
||||
const diagnostics = collectFfmpegDiagnostics(stream);
|
||||
stream.write("decoder warning\nhttps://user:partial-secret@");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(diagnostics.getTail()).toContain("decoder warning");
|
||||
expect(diagnostics.getTail()).not.toContain("partial-secret");
|
||||
stream.write("cdn.example/audio?token=last-secret");
|
||||
await finish(stream);
|
||||
expect(diagnostics.getTail()).not.toContain("partial-secret");
|
||||
expect(diagnostics.getTail()).not.toContain("last-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
const MAX_LINE_CHARS = 2048;
|
||||
const MAX_TAIL_CHARS = 4096;
|
||||
|
||||
export interface FfmpegDiagnostics {
|
||||
getTail(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain independently of the PCM pipe: unread stderr can block FFmpeg even
|
||||
* when stdout is being consumed. Keep only complete, sanitized lines. Never
|
||||
* retain a suffix of an oversized raw line: it may have lost its URL/header
|
||||
* prefix and would no longer be possible to redact safely.
|
||||
*/
|
||||
export function collectFfmpegDiagnostics(stderr: Readable | null): FfmpegDiagnostics {
|
||||
let tail = "";
|
||||
let pending = "";
|
||||
let discardLine = false;
|
||||
let suppressHeaderContinuation = false;
|
||||
let previousCR = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
|
||||
const append = (text: string): void => {
|
||||
if (text) previousCR = false;
|
||||
if (discardLine) return;
|
||||
if (pending.length + text.length > MAX_LINE_CHARS) {
|
||||
pending = "";
|
||||
discardLine = true;
|
||||
return;
|
||||
}
|
||||
pending += text;
|
||||
};
|
||||
|
||||
const finishLine = (): void => {
|
||||
if (discardLine) {
|
||||
tail = (tail + "[oversized diagnostic line omitted]\n").slice(-MAX_TAIL_CHARS);
|
||||
// An omitted line may be an authentication header. Omit folded values.
|
||||
suppressHeaderContinuation = true;
|
||||
} else if (pending) {
|
||||
const line = pending
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
||||
const authHeader = /\b(?:cookie|set-cookie|authorization|proxy-authorization)\s*[:=]/i.test(line);
|
||||
if (authHeader || (suppressHeaderContinuation && /^\s/.test(line))) {
|
||||
tail = (tail + "[authentication header omitted]\n").slice(-MAX_TAIL_CHARS);
|
||||
suppressHeaderContinuation = true;
|
||||
} else {
|
||||
suppressHeaderContinuation = false;
|
||||
const sanitized = line
|
||||
// URLs and credentials can contain quotes or spaces. Keep the error
|
||||
// prefix only; guessing a closing delimiter could expose a suffix.
|
||||
.replace(/\b[a-z][a-z\d+.-]*:\/\/[\s\S]*/i, "[URL omitted]")
|
||||
// FFmpeg can also print a request target without the scheme/host.
|
||||
.replace(/\?[\s\S]*/, "?[query omitted]")
|
||||
.replace(/\bBearer\s+[\s\S]*/i, "Bearer [omitted]");
|
||||
tail = (tail + sanitized + "\n").slice(-MAX_TAIL_CHARS);
|
||||
}
|
||||
} else {
|
||||
suppressHeaderContinuation = false;
|
||||
}
|
||||
pending = "";
|
||||
discardLine = false;
|
||||
};
|
||||
|
||||
const consume = (text: string): void => {
|
||||
const separators = /[\r\n]/g;
|
||||
let start = 0;
|
||||
for (let match = separators.exec(text); match; match = separators.exec(text)) {
|
||||
append(text.slice(start, match.index));
|
||||
if (match[0] === "\n" && previousCR) {
|
||||
previousCR = false;
|
||||
} else {
|
||||
finishLine();
|
||||
previousCR = match[0] === "\r";
|
||||
}
|
||||
start = match.index + 1;
|
||||
}
|
||||
append(text.slice(start));
|
||||
};
|
||||
|
||||
stderr?.on("data", (chunk: Buffer) => consume(decoder.write(chunk)));
|
||||
stderr?.on("end", () => {
|
||||
consume(decoder.end());
|
||||
finishLine();
|
||||
});
|
||||
// Resume explicitly as attaching a listener does not resume an already
|
||||
// paused Readable. No player pause/backpressure operation touches stderr.
|
||||
stderr?.resume();
|
||||
|
||||
return { getTail: () => tail.trimEnd() };
|
||||
}
|
||||
+270
-3
@@ -2,10 +2,20 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { Readable, PassThrough } from "node:stream";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import pino from "pino";
|
||||
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
// Only replace process creation in the regression cases below. Their pipes,
|
||||
// write callbacks and backpressure are real OS resources, rather than mocks.
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, spawn: vi.fn(actual.spawn) };
|
||||
});
|
||||
|
||||
function getHeadersArg(args: string[]): string {
|
||||
const idx = args.indexOf("-headers");
|
||||
if (idx === -1) return "";
|
||||
@@ -36,6 +46,12 @@ describe("buildFfmpegArgs", () => {
|
||||
expect(args).not.toContain("-headers");
|
||||
});
|
||||
|
||||
it("disables periodic progress stats for both network and file inputs", () => {
|
||||
for (const input of ["https://example.com/song.mp3", "C:/temp/song.audio"]) {
|
||||
expect(buildFfmpegArgs(input, 0)).toContain("-nostats");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes resilient reconnect flags for all URLs", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||
expect(args).toContain("-reconnect");
|
||||
@@ -55,13 +71,21 @@ describe("buildFfmpegArgs", () => {
|
||||
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||
});
|
||||
|
||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
||||
it("inserts -ss after -i when seekSeconds > 0", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||
const ssIdx = args.indexOf("-ss");
|
||||
const iIdx = args.indexOf("-i");
|
||||
expect(ssIdx).toBeGreaterThan(-1);
|
||||
expect(args[ssIdx + 1]).toBe("42");
|
||||
expect(ssIdx).toBeLessThan(iIdx);
|
||||
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||
});
|
||||
|
||||
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
|
||||
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
|
||||
const ssIdx = args.indexOf("-ss");
|
||||
expect(args[ssIdx + 1]).toBe("3600");
|
||||
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
|
||||
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
|
||||
});
|
||||
|
||||
it("does not insert -ss when seekSeconds is 0", () => {
|
||||
@@ -216,6 +240,249 @@ const silentLogger = {
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("AudioPlayer FFmpeg stderr handling", () => {
|
||||
const producer = `
|
||||
const pressure = 'decoder diagnostic\\n'.repeat(180000);
|
||||
process.stderr.write(pressure, () => {
|
||||
process.stderr.write('HTTP error 403 for https://user:password@cdn.example/audio?token=signed-secret#fragment-secret\\n');
|
||||
process.stderr.write('Cookie: cookie-secret\\nAuthorization: Bearer bearer-secret\\n');
|
||||
process.stderr.write('final decoder failure', () => {
|
||||
process.stdout.write(Buffer.alloc(7680), () => process.exit(1));
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
function recordedLogger() {
|
||||
const records: Array<{ level: string; fields: Record<string, unknown>; message: string }> = [];
|
||||
const capture = (level: string) => (fields: Record<string, unknown>, message: string) => {
|
||||
records.push({ level, fields, message });
|
||||
};
|
||||
return {
|
||||
records,
|
||||
logger: { ...silentLogger, info: capture("info"), warn: capture("warn") } as unknown as Logger,
|
||||
};
|
||||
}
|
||||
|
||||
async function pipeProducer(script: string) {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
let child!: ChildProcess;
|
||||
let requestedArgs: readonly string[] = [];
|
||||
let closed!: Promise<number | null>;
|
||||
vi.mocked(spawn).mockImplementationOnce((_command, args, options) => {
|
||||
requestedArgs = args ?? [];
|
||||
child = actual.spawn(process.execPath, ["-e", script], options);
|
||||
closed = new Promise((resolve) => child.once("close", resolve));
|
||||
return child;
|
||||
});
|
||||
return {
|
||||
get child() { return child; },
|
||||
get args() { return requestedArgs; },
|
||||
get closed() { return closed; },
|
||||
};
|
||||
}
|
||||
|
||||
for (const path of ["URL", "temp file"] as const) {
|
||||
it(`drains the ${path} child stderr so a large diagnostic write cannot block PCM output`, async () => {
|
||||
const { records, logger } = recordedLogger();
|
||||
const producerProcess = await pipeProducer(producer);
|
||||
const player = new AudioPlayer(logger);
|
||||
let frameCount = 0;
|
||||
player.on("frame", () => frameCount++);
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
if (path === "URL") {
|
||||
player.play("https://cdn.example/audio?token=input-secret");
|
||||
} else {
|
||||
// The real downloader marks playing before calling this file path.
|
||||
const internal = player as unknown as {
|
||||
state: string;
|
||||
spawnFfmpegFromFile(file: string, seek: number, session: number): void;
|
||||
};
|
||||
internal.state = "playing";
|
||||
internal.spawnFfmpegFromFile("downloaded.audio", 0, player.getPlaybackSessionId());
|
||||
}
|
||||
const outcome = await Promise.race([
|
||||
producerProcess.closed,
|
||||
new Promise<string>((resolve) => {
|
||||
deadline = setTimeout(() => resolve("stderr blocked audio output"), 1500);
|
||||
}),
|
||||
]);
|
||||
expect(outcome).toBe(1);
|
||||
expect(producerProcess.args).toContain("-nostats");
|
||||
await vi.waitFor(() => expect(frameCount).toBeGreaterThan(0));
|
||||
const exit = records.find((record) => record.message === "FFmpeg exited");
|
||||
expect(exit?.fields.stderr).toContain("final decoder failure");
|
||||
expect(exit?.fields.stderr).toContain("HTTP error 403");
|
||||
const logged = JSON.stringify(records);
|
||||
for (const secret of ["password", "signed-secret", "fragment-secret", "cookie-secret", "bearer-secret", "input-secret"]) {
|
||||
expect(logged).not.toContain(secret);
|
||||
}
|
||||
expect(String(exit?.fields.stderr).length).toBeLessThanOrEqual(4096);
|
||||
} finally {
|
||||
if (deadline) clearTimeout(deadline);
|
||||
player.stop();
|
||||
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
|
||||
await producerProcess.closed;
|
||||
}
|
||||
});
|
||||
|
||||
it(`does not expose the ${path} input credentials through serialized spawn errors`, async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
let child!: ChildProcess;
|
||||
let closed!: Promise<void>;
|
||||
vi.mocked(spawn).mockImplementationOnce((_command, args, options) => {
|
||||
child = actual.spawn(join(tmpdir(), "tsbot-ffmpeg-does-not-exist"), args, options);
|
||||
closed = new Promise((resolve) => child.once("close", () => resolve()));
|
||||
return child;
|
||||
});
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const emitted = new Promise<Error>((resolve) => player.once("error", resolve));
|
||||
try {
|
||||
const input = "https://user:spawn-password@cdn.example/audio?token=spawn-secret";
|
||||
if (path === "URL") {
|
||||
player.play(input);
|
||||
} else {
|
||||
(player as unknown as { spawnFfmpegFromFile(file: string, seek: number, session: number): void })
|
||||
.spawnFfmpegFromFile(input, 0, player.getPlaybackSessionId());
|
||||
}
|
||||
const error = await emitted;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain("ENOENT");
|
||||
const serialized = JSON.stringify(pino.stdSerializers.err(error));
|
||||
expect(serialized).not.toContain("spawn-secret");
|
||||
expect(serialized).not.toContain("spawn-password");
|
||||
await closed;
|
||||
} finally {
|
||||
player.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("logs intentional stop signals at info level", async () => {
|
||||
const { records, logger } = recordedLogger();
|
||||
const producerProcess = await pipeProducer("process.stdout.write(Buffer.from([0])); setInterval(() => {}, 1000);");
|
||||
const player = new AudioPlayer(logger);
|
||||
try {
|
||||
player.play("https://cdn.example/audio");
|
||||
await new Promise<void>((resolve) => producerProcess.child.stdout!.once("data", () => resolve()));
|
||||
player.stop();
|
||||
await producerProcess.closed;
|
||||
const exit = records.find((record) => record.message === "FFmpeg exited");
|
||||
expect(exit?.level).toBe("info");
|
||||
} finally {
|
||||
player.stop();
|
||||
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
|
||||
await producerProcess.closed;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a sanitized diagnostic tail when a child exits from an unexpected signal", async () => {
|
||||
const { records, logger } = recordedLogger();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: undefined,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValueOnce(child as unknown as ChildProcess);
|
||||
const player = new AudioPlayer(logger);
|
||||
try {
|
||||
player.play("https://cdn.example/audio");
|
||||
child.stderr.write("decoder crashed for https://cdn.example/audio?token=signal-secret");
|
||||
const ended = new Promise<void>((resolve) => child.stderr.once("end", resolve));
|
||||
child.stderr.end();
|
||||
await ended;
|
||||
child.emit("exit", null, "SIGSEGV");
|
||||
child.emit("close", null, "SIGSEGV");
|
||||
const exit = records.find((record) => record.message === "FFmpeg exited");
|
||||
expect(exit?.level).toBe("warn");
|
||||
expect(exit?.fields.signal).toBe("SIGSEGV");
|
||||
expect(exit?.fields.stderr).toContain("decoder crashed");
|
||||
expect(JSON.stringify(records)).not.toContain("signal-secret");
|
||||
} finally {
|
||||
player.stop();
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps draining an old child's stderr without mixing its late diagnostics or exit into a new session", async () => {
|
||||
const { records, logger } = recordedLogger();
|
||||
const makeChild = () => Object.assign(new EventEmitter(), {
|
||||
pid: undefined,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
});
|
||||
const oldChild = makeChild();
|
||||
const newChild = makeChild();
|
||||
vi.mocked(spawn)
|
||||
.mockReturnValueOnce(oldChild as unknown as ChildProcess)
|
||||
.mockReturnValueOnce(newChild as unknown as ChildProcess);
|
||||
const player = new AudioPlayer(logger);
|
||||
try {
|
||||
player.play("https://cdn.example/old");
|
||||
const oldSession = player.getPlaybackSessionId();
|
||||
player.play("https://cdn.example/new");
|
||||
const newSession = player.getPlaybackSessionId();
|
||||
oldChild.stderr.write("old late decoder failure https://cdn.example/old?token=old-secret\n".repeat(1000));
|
||||
newChild.stderr.write("new decoder failure\n");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(oldChild.stderr.readableLength).toBe(0);
|
||||
oldChild.emit("exit", 1, null);
|
||||
oldChild.stderr.end();
|
||||
oldChild.emit("close", 1, null);
|
||||
expect(player.getState()).toBe("playing");
|
||||
vi.useFakeTimers();
|
||||
const internal = player as unknown as { frameLoopRunning: boolean; startFrameLoop(): void };
|
||||
internal.frameLoopRunning = false;
|
||||
internal.startFrameLoop();
|
||||
vi.advanceTimersByTime(6000);
|
||||
const stall = records.find((record) => record.message === "FFmpeg stopped outputting data, ending track");
|
||||
expect(stall?.fields.sessionId).toBe(newSession);
|
||||
expect(stall?.fields.stderr).toContain("new decoder failure");
|
||||
expect(stall?.fields.stderr).not.toContain("old late decoder failure");
|
||||
const oldExit = records.find((record) => record.message === "FFmpeg exited");
|
||||
expect(oldExit?.fields.sessionId).toBe(oldSession);
|
||||
expect(JSON.stringify(records)).not.toContain("old-secret");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
player.stop();
|
||||
oldChild.stdout.destroy();
|
||||
oldChild.stderr.destroy();
|
||||
newChild.stdout.destroy();
|
||||
newChild.stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the current child's sanitized diagnostic tail when the stall watchdog ends playback", async () => {
|
||||
const { records, logger } = recordedLogger();
|
||||
const producerProcess = await pipeProducer(`
|
||||
process.stderr.write('HTTP error 403: https://cdn.example/audio?token=stall-secret\\n');
|
||||
process.stdout.write(Buffer.from([0]));
|
||||
setInterval(() => {}, 1000);
|
||||
`);
|
||||
const player = new AudioPlayer(logger);
|
||||
try {
|
||||
player.play("https://cdn.example/audio");
|
||||
await new Promise<void>((resolve) => producerProcess.child.stdout!.once("data", () => resolve()));
|
||||
vi.useFakeTimers();
|
||||
// Restart scheduling under the test clock, without changing EOF state.
|
||||
const internal = player as unknown as { frameLoopRunning: boolean; startFrameLoop(): void };
|
||||
internal.frameLoopRunning = false;
|
||||
internal.startFrameLoop();
|
||||
vi.advanceTimersByTime(6000);
|
||||
const stall = records.find((record) => record.message === "FFmpeg stopped outputting data, ending track");
|
||||
expect(stall?.fields.stderr).toContain("HTTP error 403");
|
||||
expect(JSON.stringify(records)).not.toContain("stall-secret");
|
||||
expect(player.getState()).toBe("idle");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
player.stop();
|
||||
if (producerProcess.child.exitCode === null) producerProcess.child.kill("SIGKILL");
|
||||
await producerProcess.closed;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function applyPlayerVolume(player: AudioPlayer, pcm: Buffer): Buffer {
|
||||
return (
|
||||
player as unknown as { applyVolume(input: Buffer): Buffer }
|
||||
|
||||
+67
-13
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { collectFfmpegDiagnostics, type FfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||
@@ -52,6 +53,17 @@ export function getFfmpegCommand(): string {
|
||||
return resolvedFfmpeg;
|
||||
}
|
||||
|
||||
function safeFfmpegSpawnError(err: Error): Error {
|
||||
// Node spawn errors include spawnargs; Pino's Error serializer copies them,
|
||||
// including the signed input URL. Preserve a known OS category only.
|
||||
const allowedCodes = new Set(["ENOENT", "EACCES", "EPERM", "ENOEXEC", "EMFILE", "ENFILE", "ENOMEM", "EAGAIN", "EINVAL"]);
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
const safeCode = typeof code === "string" && allowedCodes.has(code) ? code : undefined;
|
||||
const safeError = new Error(`FFmpeg failed to start${safeCode ? ` (${safeCode})` : ""}`);
|
||||
if (safeCode) Object.assign(safeError, { code: safeCode });
|
||||
return safeError;
|
||||
}
|
||||
|
||||
const BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
@@ -74,10 +86,11 @@ export function cleanupTempDir(dir: string): void {
|
||||
}
|
||||
|
||||
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
const args: string[] = [];
|
||||
const args: string[] = ["-nostats"];
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
|
||||
|
||||
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
|
||||
if (isBilibili) {
|
||||
args.push(
|
||||
"-headers",
|
||||
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||
@@ -103,8 +116,16 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
"-reconnect_on_http_error", "4xx,5xx",
|
||||
);
|
||||
}
|
||||
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
|
||||
// to the byte offset. Output-side seek would download and decode everything
|
||||
// before the target first — minutes for a resume deep into a 3-hour video
|
||||
// (#161), long enough to trip the stall watchdog.
|
||||
const inputSideSeek = isBilibili;
|
||||
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
|
||||
args.push("-i", url);
|
||||
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
||||
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
|
||||
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -158,6 +179,8 @@ const FRAME_DURATION_MS = 20;
|
||||
|
||||
export class AudioPlayer extends EventEmitter {
|
||||
private ffmpeg: ChildProcess | null = null;
|
||||
private ffmpegDiagnostics: FfmpegDiagnostics | null = null;
|
||||
private readonly intentionalCleanup = new WeakSet<ChildProcess>();
|
||||
private encoder: Encoder;
|
||||
private state: PlayerState = "idle";
|
||||
private volume = 75;
|
||||
@@ -249,6 +272,9 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = this.ffmpeg;
|
||||
const diagnostics = collectFfmpegDiagnostics(child.stderr);
|
||||
this.ffmpegDiagnostics = diagnostics;
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
@@ -271,7 +297,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
|
||||
// 只有当前会话的进程结束才置空变量
|
||||
if (this.sessionId === currentSessionId) {
|
||||
@@ -279,11 +304,21 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// close follows stderr's end, so final unterminated diagnostics are ready.
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
const context = { pid: currentPid, sessionId: currentSessionId, code, signal };
|
||||
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
|
||||
} else {
|
||||
this.logger.info(context, "FFmpeg exited");
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
this.emit("error", safeFfmpegSpawnError(err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -323,10 +358,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
);
|
||||
this.downloader = ps;
|
||||
|
||||
let stderrTail = "";
|
||||
ps.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-500);
|
||||
});
|
||||
const diagnostics = collectFfmpegDiagnostics(ps.stderr);
|
||||
|
||||
ps.on("exit", (code, signal) => {
|
||||
if (this.sessionId !== sessionId) {
|
||||
@@ -335,7 +367,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
this.downloader = null;
|
||||
if (code !== 0) {
|
||||
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.state = "idle";
|
||||
@@ -347,6 +378,12 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
|
||||
});
|
||||
|
||||
ps.on("close", (code, signal) => {
|
||||
if (!this.intentionalCleanup.has(ps) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ pid: ps.pid, sessionId, code, signal, stderr: diagnostics.getTail() }, "PowerShell download failed");
|
||||
}
|
||||
});
|
||||
|
||||
ps.on("error", (err) => {
|
||||
if (this.sessionId !== sessionId) return;
|
||||
this.downloader = null;
|
||||
@@ -377,6 +414,9 @@ export class AudioPlayer extends EventEmitter {
|
||||
const args = buildFfmpegArgs(tempFile, seekSeconds);
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = this.ffmpeg;
|
||||
const diagnostics = collectFfmpegDiagnostics(child.stderr);
|
||||
this.ffmpegDiagnostics = diagnostics;
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
@@ -396,7 +436,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
if (this.sessionId === sessionId) {
|
||||
this.ffmpeg = null;
|
||||
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
|
||||
@@ -404,11 +443,20 @@ export class AudioPlayer extends EventEmitter {
|
||||
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
|
||||
});
|
||||
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
const context = { pid: currentPid, sessionId, code, signal };
|
||||
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
|
||||
} else {
|
||||
this.logger.info(context, "FFmpeg exited");
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
if (this.sessionId === sessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
this.emit("error", safeFfmpegSpawnError(err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -534,6 +582,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
// 立即清空缓冲区,确保切歌瞬间静音 (
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.ffmpegDiagnostics = null;
|
||||
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
@@ -548,6 +597,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
if (this.downloader) {
|
||||
const ps = this.downloader;
|
||||
this.downloader = null;
|
||||
this.intentionalCleanup.add(ps);
|
||||
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
@@ -571,6 +621,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||
this.intentionalCleanup.add(proc);
|
||||
if (!globalActivePids.has(pid)) return;
|
||||
|
||||
try {
|
||||
@@ -655,6 +706,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
duration: this.currentSongDuration,
|
||||
remaining: Math.round(this.currentSongDuration - elapsed),
|
||||
nearEnd: isNearEnd,
|
||||
stderr: this.ffmpegDiagnostics?.getTail() ?? "",
|
||||
}, "FFmpeg stopped outputting data, ending track");
|
||||
this.frameLoopRunning = false;
|
||||
// The outer gate guarantees state==="playing" here, so no !=="idle"
|
||||
@@ -825,6 +877,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().
|
||||
|
||||
@@ -605,4 +605,141 @@ describe("PlayQueue", () => {
|
||||
expect(q.list().map((s) => s.id)).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #141: in Random/RandomLoop, next() picks from the shuffle bag and
|
||||
// ignores array order, so a song spliced in by addNext (!pn) was NOT played
|
||||
// next — it just waited for its random turn like any other song. addNext now
|
||||
// records the insert slot on the forward stack, which next() honours first.
|
||||
describe("addNext in random modes (issue #141)", () => {
|
||||
for (const mode of [PlayMode.Random, PlayMode.RandomLoop]) {
|
||||
it(`plays the inserted song next in ${mode} mode`, () => {
|
||||
queue.setMode(mode);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.play(); // current = 0 (a)
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
}
|
||||
|
||||
it("plays consecutive inserts in the order the queue displays them", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.play(); // current = 0 (a)
|
||||
queue.addNext(makeSong("x"));
|
||||
queue.addNext(makeSong("y")); // splices in front of x, as in sequential
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "y", "x", "b", "c", "d"]);
|
||||
expect(queue.next()?.id).toBe("y");
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
|
||||
it("honours the insert even after the shuffle bag is exhausted", () => {
|
||||
// Random (non-loop) returns null once every song has played. Songs added
|
||||
// afterwards must still be reachable via !pn — and with TWO of them the
|
||||
// order can only come from the forward stack, not from the bag having a
|
||||
// single remaining candidate.
|
||||
queue.setMode(PlayMode.Random);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.play();
|
||||
for (let i = 0; i < 3; i++) queue.next();
|
||||
expect(queue.next()).toBeNull(); // bag exhausted
|
||||
queue.addNext(makeSong("x"));
|
||||
queue.addNext(makeSong("y"));
|
||||
queue.addNext(makeSong("z"));
|
||||
expect(queue.next()?.id).toBe("z");
|
||||
expect(queue.next()?.id).toBe("y");
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
|
||||
it("pops past a prev() marker to reach the pending insert", () => {
|
||||
// prev() shares the forward stack, and in random mode with no history it
|
||||
// pushes the current index and then returns null. next() must walk past
|
||||
// those self-referencing markers instead of consuming one and giving up
|
||||
// to the shuffle bag.
|
||||
queue.setMode(PlayMode.Random);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.play(); // a
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.prev()).toBeNull();
|
||||
expect(queue.prev()).toBeNull();
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
|
||||
it("plays each song exactly once — the insert is not replayed later", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.play(); // a
|
||||
queue.addNext(makeSong("x"));
|
||||
queue.addNext(makeSong("y"));
|
||||
|
||||
const played = [queue.current()!.id];
|
||||
for (let i = 0; i < 5; i++) played.push(queue.next()!.id);
|
||||
expect(queue.next()).toBeNull(); // bag exhausted
|
||||
expect(played.slice(0, 3)).toEqual(["a", "y", "x"]);
|
||||
expect(new Set(played).size).toBe(6);
|
||||
});
|
||||
|
||||
it("keeps the insert reachable after an earlier song is removed", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
|
||||
queue.playAt(2); // current = 2 (c)
|
||||
queue.addNext(makeSong("x")); // [a, b, c, x, d]
|
||||
queue.remove(0); // [b, c, x, d] — x slides from 3 to 2
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
|
||||
it("drops the entry when the inserted song is itself removed", () => {
|
||||
// Leaving the stale entry behind would not throw — index 2 still exists
|
||||
// after the removal, it just points at a different song. So the queue is
|
||||
// arranged with exactly one song the shuffle bag can legally return:
|
||||
// anything else means the dead forward entry was honoured.
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
for (const id of ["a", "b", "c"]) queue.add(makeSong(id));
|
||||
queue.playAt(0); // current = 0 (a), played = {0}
|
||||
queue.next(); // b or c — two of the three are now played
|
||||
const remaining = queue.list().find((s) => s.id !== "a" && s.id !== queue.current()!.id)!;
|
||||
queue.addNext(makeSong("x")); // spliced at currentIndex+1
|
||||
queue.remove(queue.getCurrentIndex() + 1); // …and removed again
|
||||
expect(queue.list().map((s) => s.id)).not.toContain("x");
|
||||
expect(queue.next()?.id).toBe(remaining.id);
|
||||
});
|
||||
|
||||
it("never yields a stale index under interleaved inserts and removals", () => {
|
||||
// The forward stack holds array indices, so every splice has to shift
|
||||
// them. next() returning `undefined` here (an out-of-range index) reads
|
||||
// as end-of-queue to BotInstance.playNext and silently stops playback.
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
for (let i = 0; i < 6; i++) queue.add(makeSong(`s${i}`));
|
||||
queue.play();
|
||||
for (let step = 0; step < 200; step++) {
|
||||
const roll = step % 4;
|
||||
if (roll === 0) queue.addNext(makeSong(`x${step}`));
|
||||
else if (roll === 1 && queue.size() > 1) queue.remove(step % queue.size());
|
||||
else {
|
||||
const song = queue.next();
|
||||
expect(song === null || song === queue.current()).toBe(true);
|
||||
if (song !== null) expect(song).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves sequential/loop behaviour untouched", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
for (const id of ["a", "b", "c"]) queue.add(makeSong(id));
|
||||
queue.play(); // a
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
expect(queue.next()?.id).toBe("b");
|
||||
expect(queue.next()?.id).toBe("c");
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("still appends (no forward entry) when nothing is playing", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.addNext(makeSong("x")); // currentIndex is still -1 → plain push
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "x"]);
|
||||
queue.play(); // a — a stray forward entry would have hijacked this
|
||||
expect(queue.current()?.id).toBe("a");
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
-6
@@ -60,8 +60,12 @@ export class PlayQueue {
|
||||
* or queue empty), so the existing "add → idle bot starts playing"
|
||||
* flow continues to work.
|
||||
*
|
||||
* Shifts playedIndices and history entries > currentIndex by +1 so
|
||||
* their references stay valid after the splice.
|
||||
* Shifts playedIndices, history and forwardStack entries > currentIndex
|
||||
* by +1 so their references stay valid after the splice.
|
||||
*
|
||||
* In the random modes the array position alone means nothing — next()
|
||||
* picks from the shuffle bag — so the insert slot is also recorded on
|
||||
* the forward stack, which next() consults first (issue #141).
|
||||
*/
|
||||
addNext(song: QueuedSong): void {
|
||||
if (this.currentIndex < 0 || this.songs.length === 0) {
|
||||
@@ -80,6 +84,23 @@ export class PlayQueue {
|
||||
this.history = this.history.map((i) =>
|
||||
i > this.currentIndex ? i + 1 : i,
|
||||
);
|
||||
|
||||
this.forwardStack = this.forwardStack.map((i) =>
|
||||
i > this.currentIndex ? i + 1 : i,
|
||||
);
|
||||
|
||||
// Push AFTER the shift, or the slot we just claimed would be shifted
|
||||
// too. Stacking makes repeated !pn play in the order the queue shows
|
||||
// them (each insert lands in front of the previous one), matching what
|
||||
// sequential mode does with the same array. Bounded like history: drop the
|
||||
// OLDEST pending entry rather than refusing the newest, so the song the
|
||||
// user just asked for is always the one that gets honoured.
|
||||
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
|
||||
this.forwardStack.push(insertAt);
|
||||
if (this.forwardStack.length > PlayQueue.HISTORY_LIMIT) {
|
||||
this.forwardStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(index: number): QueuedSong | null {
|
||||
@@ -106,6 +127,13 @@ export class PlayQueue {
|
||||
.filter((idx) => idx !== index)
|
||||
.map((idx) => (idx > index ? idx - 1 : idx));
|
||||
|
||||
// …and for the forward stack, which now also carries !pn insert slots
|
||||
// (issue #141). Left unshifted, a removal elsewhere in the queue would
|
||||
// silently repoint the entry at whatever song slid into that slot.
|
||||
this.forwardStack = this.forwardStack
|
||||
.filter((idx) => idx !== index)
|
||||
.map((idx) => (idx > index ? idx - 1 : idx));
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -158,16 +186,23 @@ export class PlayQueue {
|
||||
}
|
||||
case PlayMode.Random:
|
||||
case PlayMode.RandomLoop: {
|
||||
// 优先回到前进栈记录的位置(prev 退回的歌)
|
||||
if (this.forwardStack.length > 0) {
|
||||
// 优先回到前进栈记录的位置(prev 退回的歌,或 !pn 插入的歌)。
|
||||
// Keep popping past entries that no longer point anywhere useful,
|
||||
// the way prev() walks past stale history entries. Without the loop a
|
||||
// prev() that pushed the current index would swallow the pending !pn
|
||||
// entry behind it. The range check is belt-and-braces — addNext and
|
||||
// remove keep the stack in sync — but an out-of-range index here would
|
||||
// set currentIndex out of bounds and hand back `undefined`, which
|
||||
// BotInstance.playNext reads as end-of-queue and stops playback.
|
||||
while (this.forwardStack.length > 0) {
|
||||
const target = this.forwardStack.pop()!;
|
||||
if (target !== this.currentIndex) {
|
||||
if (target < 0 || target >= this.songs.length) continue;
|
||||
if (target === this.currentIndex) continue;
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = target;
|
||||
this.playedIndices.add(target);
|
||||
return this.songs[target];
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle bag: pick uniformly from the songs not yet played this
|
||||
// cycle, so every song plays once before any repeats (NetEase/QQ
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+184
-17
@@ -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. */
|
||||
@@ -295,7 +303,13 @@ export class BotInstance extends EventEmitter {
|
||||
const relPath = this.database.getCustomAvatarPath(this.id);
|
||||
if (relPath) {
|
||||
const buf = this.avatarStore.read(relPath);
|
||||
if (buf) this.profileManager.setCustomAvatar(buf);
|
||||
// loadCustomAvatar, NOT setCustomAvatar (#148): we are still in the
|
||||
// constructor, so tsClient has not connected. setCustomAvatar would
|
||||
// start a file transfer right here and fail. profileManager.onConnect()
|
||||
// uploads it for real once the handshake completes.
|
||||
// `length > 0` because avatarStore.write is delete-then-write, so a
|
||||
// crash mid-write leaves a 0-byte file that is truthy as a Buffer.
|
||||
if (buf && buf.length > 0) this.profileManager.loadCustomAvatar(buf);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
|
||||
@@ -316,8 +330,28 @@ export class BotInstance extends EventEmitter {
|
||||
});
|
||||
|
||||
this.player.on("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");
|
||||
this.playNext().catch((err) => {
|
||||
return this.playNext();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error({ err }, "playNext failed after trackEnd");
|
||||
});
|
||||
});
|
||||
@@ -450,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);
|
||||
}
|
||||
@@ -962,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");
|
||||
@@ -1102,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);
|
||||
@@ -1114,7 +1244,7 @@ export class BotInstance extends EventEmitter {
|
||||
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
|
||||
* forms (issue #90):
|
||||
* 1) "#N" — the Nth result of the previous !search
|
||||
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
|
||||
* 2) id <id> / URL — an exact song (disambiguates same-name songs)
|
||||
* 3) plain text — search, returning the single most-popular hit (legacy)
|
||||
*/
|
||||
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
|
||||
@@ -1128,10 +1258,15 @@ export class BotInstance extends EventEmitter {
|
||||
return { error: `No recent search. Use ${p}search <name> first.` };
|
||||
if (sel > this.lastSearchResults.length)
|
||||
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
|
||||
return { song: this.lastSearchResults[sel - 1] };
|
||||
let song = this.lastSearchResults[sel - 1];
|
||||
if (song.platform === "bilibili") {
|
||||
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
|
||||
if (detail) song = { ...detail, platform: "bilibili" };
|
||||
}
|
||||
return { song };
|
||||
}
|
||||
|
||||
// 2) id:/URL — fetch that exact song.
|
||||
// 2) id/URL — fetch that exact song.
|
||||
const ref = parseSongRef(args);
|
||||
if (ref) {
|
||||
if (ref.platform) this.assertProviderEnabled(ref.platform);
|
||||
@@ -1145,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> {
|
||||
@@ -1159,13 +1299,13 @@ export class BotInstance extends EventEmitter {
|
||||
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
|
||||
);
|
||||
return [
|
||||
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
|
||||
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id <id>):`,
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id <id> | URL>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const song0 = song!;
|
||||
@@ -1259,7 +1399,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id <id> | URL>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const s = song!;
|
||||
@@ -1283,7 +1423,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id <id> | URL>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const s = song!;
|
||||
@@ -1314,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) =>
|
||||
@@ -1326,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"));
|
||||
@@ -1489,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);
|
||||
@@ -1498,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 {
|
||||
@@ -1840,8 +2007,8 @@ export class BotInstance extends EventEmitter {
|
||||
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
||||
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
||||
`${p}play #N — Play the Nth result of the last ${p}search`,
|
||||
`${p}play id:<id> — Play an exact song by id / URL`,
|
||||
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
||||
`${p}play id <id> — Play an exact song by id / URL`,
|
||||
`${p}add <song> — Add to queue (also accepts #N / id <id> / URL)`,
|
||||
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
||||
`${p}pause/resume — Pause/resume`,
|
||||
`${p}next/prev — Next/previous`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Client, generateIdentity } from "@honeybbq/teamspeak-client";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import { TS6HttpQuery } from "../ts-protocol/http-query.js";
|
||||
import type { TS3Client } from "../ts-protocol/client.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
|
||||
@@ -14,6 +16,9 @@ function makeMockTs(): TS3Client & {
|
||||
get clearCalls() { return clears; },
|
||||
getHost: () => "127.0.0.1",
|
||||
getHttpQuery: () => null,
|
||||
getClientId: () => 17,
|
||||
getChannelId: () => 5n,
|
||||
execCommand: vi.fn().mockResolvedValue(undefined),
|
||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -145,3 +150,293 @@ describe("BotProfileManager custom avatar precedence", () => {
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// #148: the persisted avatar is loaded in the BotInstance constructor, before
|
||||
// tsClient.connect() has run. Loading it must not touch the wire at all.
|
||||
describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
|
||||
let ts: ReturnType<typeof makeMockTs>;
|
||||
beforeEach(() => { ts = makeMockTs(); });
|
||||
|
||||
it("does not upload or clear anything when called before connect", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
expect(ts.fileTransferInitUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the loaded avatar is uploaded once onConnect fires", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
|
||||
await flush();
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([7, 7, 7]))).toBe(true);
|
||||
});
|
||||
|
||||
it("survives a reconnect: onConnect re-applies the loaded avatar every time", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.loadCustomAvatar(Buffer.from([8]));
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("loading null leaves the wire untouched and onConnect stays quiet", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.loadCustomAvatar(null);
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("setCustomAvatar still uploads immediately after connect (post-connect edit unchanged)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.loadCustomAvatar(Buffer.from([1]));
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
ts.uploadCalls.length = 0;
|
||||
pm.setCustomAvatar(Buffer.from([2, 2]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotProfileManager channel description follows the bot (#159)", () => {
|
||||
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
|
||||
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
|
||||
let channelEdits: () => string[];
|
||||
|
||||
beforeEach(() => {
|
||||
ts = makeMockTs() as any;
|
||||
ts.cid = 5n;
|
||||
(ts as any).getChannelId = () => ts.cid;
|
||||
channelEdits = () =>
|
||||
(ts.execCommand as any).mock.calls
|
||||
.map((c: any[]) => c[0] as string)
|
||||
.filter((cmd: string) => cmd.startsWith("channeledit"));
|
||||
});
|
||||
|
||||
it("clears the old channel and fills the new one when moved while playing", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(channelEdits()).toEqual([
|
||||
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
|
||||
]);
|
||||
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
|
||||
const edits = channelEdits();
|
||||
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
|
||||
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
|
||||
});
|
||||
|
||||
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
await pm.onSongChange(null);
|
||||
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
|
||||
});
|
||||
|
||||
it("a move while idle touches no channel description", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(channelEdits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a move is ignored when the channel description feature is off", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(channelEdits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("an event for the channel the description is already in is a no-op", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
await pm.onChannelMoved(5n);
|
||||
expect(channelEdits()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeHttpProfile(partial: Partial<typeof cfgOff> = {}) {
|
||||
const ts = makeMockTs() as any;
|
||||
const state = { clid: 17, cid: 5n };
|
||||
const descriptions = new Map<number, string>();
|
||||
const http = new TS6HttpQuery({ host: "127.0.0.1", port: 10080 });
|
||||
const request = vi.spyOn(http, "request").mockImplementation(async (_method, path, body) => {
|
||||
if (path.includes("clientlist")) {
|
||||
return { status: 200, body: { body: [{ clid: String(state.clid), cid: String(state.cid) }], status: { code: 0, message: "ok" } } };
|
||||
}
|
||||
if (path.includes("channeledit")) descriptions.set(Number(body!.cid), String(body!.channel_description));
|
||||
return { status: 200, body: { status: { code: 0, message: "ok" } } };
|
||||
});
|
||||
ts.getHttpQuery = () => http;
|
||||
ts.getClientId = () => state.clid;
|
||||
ts.getChannelId = () => state.cid;
|
||||
const logger: any = { child: () => logger, info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const pm = new BotProfileManager(ts, logger, { ...cfgOff, ...partial }, "Bot");
|
||||
return { pm, ts, state, http, request, descriptions, logger };
|
||||
}
|
||||
|
||||
describe("BotProfileManager checked TS6 profile lifecycle", () => {
|
||||
it("clears the old channel through HTTP Query when moved, even without full-client edit permission", async () => {
|
||||
const { pm, ts, state, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.execCommand.mockRejectedValue(new Error("insufficient client permissions"));
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(descriptions.get(5)).toContain("X - Y");
|
||||
state.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
expect(ts.sendCommandNoWait).not.toHaveBeenCalled();
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards an old client-list reply after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
state.cid = 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("channeledit"))).toEqual([]);
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("does not restore the previous remembered channel when a write completes after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 200, body: { status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when playback stops", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onSongChange(null);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when the bot is moved", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onChannelMoved(9n);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.has(5)).toBe(false);
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
});
|
||||
|
||||
it("does not disable the new connection after an old write returns a permission failure", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await update;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
});
|
||||
|
||||
it("resolves an unknown channel and sends raw newlines with one targeted description update", async () => {
|
||||
const { pm, ts, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
state.cid = 5n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("clientedit"))).toEqual([
|
||||
["POST", "/1/clientedit?sid=1", { clid: 17, client_description: "X - Y [Z]" }],
|
||||
]);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 5, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve or write a disconnected client", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
state.clid = 0;
|
||||
state.cid = 0n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports HTTP lookup permission errors once and retries after reconnect", async () => {
|
||||
const { pm, ts, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
request.mockResolvedValue({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await pm.onSongChange(fakeSong);
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks self clientupdate permission responses and retries only after reconnect", async () => {
|
||||
const { pm, ts, request, logger } = makeHttpProfile({ nicknameEnabled: true, awayStatusEnabled: true });
|
||||
const client: any = new Client(generateIdentity(0), "127.0.0.1:9987", "Bot");
|
||||
const commands: string[] = [];
|
||||
client.handler.sendPacket = vi.fn((_type, data: Buffer) => {
|
||||
const command = data.toString();
|
||||
commands.push(command);
|
||||
const returnCode = command.match(/return_code=(\d+)/)?.[1];
|
||||
client.handler.onPacket({ typeFlagged: 2, data: Buffer.from(`error id=2568 msg=insufficient\\sclient\\spermissions${returnCode ? ` return_code=${returnCode}` : ""}`) });
|
||||
});
|
||||
ts.sendCommandNoWait.mockImplementation((command: string) => client.sendCommandNoWait(command));
|
||||
ts.execCommand.mockImplementation((command: string) => client.execCommand(command));
|
||||
await pm.onSongChange(null);
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toMatch(/^clientupdate client_nickname=Bot client_away=1 client_away_message=等待播放 return_code=\d+$/);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(logger.info.mock.calls.some((call: any[]) => call[1] === "Client properties updated (nickname + away)")).toBe(false);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+228
-70
@@ -13,6 +13,13 @@ const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
interface ProfileUpdateContext {
|
||||
generation: number;
|
||||
channelGeneration: number;
|
||||
clientId: number;
|
||||
httpQuery: ReturnType<TS3Client["getHttpQuery"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
@@ -32,6 +39,13 @@ export class BotProfileManager {
|
||||
* pushed immediately (idle) or wait for the next stop event (playing).
|
||||
*/
|
||||
private currentSong: QueuedSong | null = null;
|
||||
/**
|
||||
* Channel whose description currently holds our now-playing text, or null
|
||||
* if we have not written one. Remembered so that when the bot is moved we
|
||||
* can still clean up the channel it was taken out of (#159) — by then
|
||||
* getChannelId() already reports the new channel.
|
||||
*/
|
||||
private channelDescCid: bigint | null = null;
|
||||
|
||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||
private permDenied = {
|
||||
@@ -50,6 +64,8 @@ export class BotProfileManager {
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
/** Channel moves supersede channel writes without cancelling avatar work. */
|
||||
private channelGeneration = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
@@ -65,6 +81,20 @@ export class BotProfileManager {
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/**
|
||||
* Store a persisted custom avatar WITHOUT touching TeamSpeak (#148).
|
||||
*
|
||||
* Used during BotInstance construction, when the TS connection does not
|
||||
* exist yet: setCustomAvatar would immediately fire the three-step file
|
||||
* transfer (fileTransferInitUpload → uploadFileData → clientupdate) against
|
||||
* a client that has not connected, so the upload always failed and the
|
||||
* saved avatar never appeared. onConnect() re-applies this.customAvatar
|
||||
* once the handshake completes, so loading it silently here loses nothing.
|
||||
*/
|
||||
loadCustomAvatar(buffer: Buffer | null): void {
|
||||
this.customAvatar = buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/clear the persistent idle avatar. Pass null to remove.
|
||||
*
|
||||
@@ -98,20 +128,25 @@ export class BotProfileManager {
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = song;
|
||||
const context = this.createUpdateContext();
|
||||
|
||||
// 1. Avatar first — file transfer uses its own response tracker and
|
||||
// must run before sendCommandNoWait calls whose orphaned responses
|
||||
// could confuse the command matcher.
|
||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||
if (this.generation !== gen) return; // superseded
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 2. Checked clientupdate sent by the visible client itself.
|
||||
await this.updateClientProperties(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
await this.updateDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 4. Checked channel description update.
|
||||
await this.updateChannelDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
@@ -119,7 +154,10 @@ export class BotProfileManager {
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = null;
|
||||
// Channel ids are per-server; never carry one across a (re)connect.
|
||||
this.channelDescCid = null;
|
||||
this.permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
@@ -136,6 +174,32 @@ export class BotProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the bot itself has been moved to another channel (#159).
|
||||
* Clears the now-playing text from the channel it left and, if a song is
|
||||
* playing, writes it to the channel it is in now.
|
||||
*/
|
||||
async onChannelMoved(newChannelId: bigint): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
const oldChannelId = this.channelDescCid;
|
||||
if (oldChannelId === newChannelId) return;
|
||||
this.channelGeneration++;
|
||||
const context = this.createUpdateContext();
|
||||
const song = this.currentSong;
|
||||
try {
|
||||
if (oldChannelId !== null) {
|
||||
if (!await this.writeChannelDescription(oldChannelId, "", context)) return;
|
||||
this.channelDescCid = null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
return;
|
||||
}
|
||||
if (song) {
|
||||
await this.updateChannelDescription(song, context, newChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): ProfileConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
@@ -238,53 +302,56 @@ export class BotProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
private async updateDescription(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
|
||||
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
|
||||
// be misreported as success.
|
||||
const result = await httpQuery.clientUpdate({ client_description: text });
|
||||
this.logger.info({ status: result.status }, "Description updated");
|
||||
} else {
|
||||
// clientupdate rejects client_description (error 1538).
|
||||
// Use clientedit on our own clid instead — this is what
|
||||
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||
const clid = this.tsClient.getClientId();
|
||||
|
||||
const clid = context.clientId;
|
||||
if (clid <= 0) return;
|
||||
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||
// remaining profile updates (channeledit, now-playing msg).
|
||||
|
||||
const httpQuery = context.httpQuery;
|
||||
|
||||
if (httpQuery) {
|
||||
// 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 {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
this.logger.info("Description updated");
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info({ clid }, "Description updated");
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
if (this.isCurrentUpdate(context)) this.handleFeatureError("description", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send a single `clientupdate` command that sets nickname
|
||||
* and away status together, avoiding multiple round-trips that can
|
||||
* cause command-queue timeouts on the TS3 protocol.
|
||||
*
|
||||
* Values are collected as raw strings/numbers. The TS6 HTTP path
|
||||
* forwards them as JSON (the server expects real spaces, not `\s`);
|
||||
* the TS3 wire path escapes them on the fly. Previously the code
|
||||
* escaped upfront and then split the escaped string to build the
|
||||
* JSON body, so TS6 received literal backslashes and silently
|
||||
* rejected the update.
|
||||
* and away status together. The full client sends this command on both
|
||||
* TS3 and TS6, with a return code so permission failures are observable.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
private async updateClientProperties(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.isCurrentUpdate(context) || context.clientId <= 0) return;
|
||||
const rawProps: Record<string, string | number> = {};
|
||||
|
||||
// --- Nickname ---
|
||||
@@ -305,39 +372,38 @@ export class BotProfileManager {
|
||||
rawProps.client_away = 0;
|
||||
} else {
|
||||
rawProps.client_away = 1;
|
||||
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
|
||||
rawProps.client_away_message = "等待播放";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rawProps).length === 0) return;
|
||||
|
||||
try {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
|
||||
const result = await httpQuery.clientUpdate(rawProps);
|
||||
// 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(
|
||||
{ status: result.status, props: Object.keys(rawProps) },
|
||||
{
|
||||
clid: context.clientId,
|
||||
props: Object.keys(rawProps),
|
||||
},
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
} else {
|
||||
// TS3 wire protocol: escape string values inline.
|
||||
// sendCommandNoWait: the TS3 full-client protocol often
|
||||
// doesn't return a timely error response for clientupdate,
|
||||
// causing execCommand to time out after 10s.
|
||||
const parts = Object.entries(rawProps).map(([k, v]) =>
|
||||
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
|
||||
);
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||
this.logger.info(
|
||||
{ props: Object.keys(rawProps) },
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Flag both features on permission error
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
if (rawProps.client_nickname !== undefined) this.handleFeatureError("nickname", err);
|
||||
if (rawProps.client_away !== undefined) this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,33 +452,106 @@ export class BotProfileManager {
|
||||
return str.slice(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||
private async updateChannelDescription(
|
||||
song: QueuedSong | null,
|
||||
context: ProfileUpdateContext,
|
||||
targetChannelId?: bigint,
|
||||
): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
if (!this.isCurrentChannelUpdate(context) || context.clientId <= 0) return;
|
||||
|
||||
try {
|
||||
const channelId = this.tsClient.getChannelId();
|
||||
if (channelId === 0n) return; // unknown channel
|
||||
// A stop already knows which channel to clear if a write succeeded.
|
||||
// Avoid a needless client-list lookup that could prevent that cleanup.
|
||||
let channelId = !song && this.channelDescCid !== null
|
||||
? this.channelDescCid
|
||||
: targetChannelId ?? this.tsClient.getChannelId();
|
||||
|
||||
// TS6 full-client may report channelID() as 0 even after the
|
||||
// visible music client has already joined a channel.
|
||||
// Fall back to HTTP Query and resolve our real clid -> cid.
|
||||
if (channelId === 0n) {
|
||||
const httpQuery = context.httpQuery;
|
||||
const clid = context.clientId;
|
||||
|
||||
if (httpQuery && clid > 0) {
|
||||
const result = await httpQuery.clientList();
|
||||
if (!this.isCurrentChannelUpdate(context)) return;
|
||||
|
||||
const payload = result.body as {
|
||||
body?: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
const me = payload?.body?.find(
|
||||
(client) => Number(client.clid) === clid,
|
||||
);
|
||||
|
||||
if (me?.cid) {
|
||||
channelId = BigInt(me.cid);
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
clid,
|
||||
cid: channelId.toString(),
|
||||
},
|
||||
"Resolved channel ID via HTTP Query",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=`,
|
||||
);
|
||||
if (channelId <= 0n) return;
|
||||
if (await this.writeChannelDescription(channelId, "", context)) this.channelDescCid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId <= 0n) return;
|
||||
|
||||
const lines = [
|
||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||
`♪ 正在播放: ${song.name} - ${song.artist}`,
|
||||
`专辑: ${song.album}`,
|
||||
`平台: ${song.platform}`,
|
||||
];
|
||||
const desc = lines.join("\\n");
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
);
|
||||
|
||||
// HTTP Query uses a normal JSON string, so use real newlines here.
|
||||
const desc = lines.join("\n");
|
||||
|
||||
if (await this.writeChannelDescription(channelId, desc, context)) this.channelDescCid = channelId;
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Both move cleanup and ordinary writes use the same checked transport. */
|
||||
private async writeChannelDescription(
|
||||
channelId: bigint,
|
||||
description: string,
|
||||
context: ProfileUpdateContext,
|
||||
): Promise<boolean> {
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
let status: number | undefined;
|
||||
if (context.httpQuery) {
|
||||
const result = await context.httpQuery.channelEdit(Number(channelId), {
|
||||
channel_description: description,
|
||||
});
|
||||
status = result.status;
|
||||
} else {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(description)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
this.logger.info(
|
||||
{ status, cid: channelId.toString() },
|
||||
description ? "Channel description updated" : "Channel description cleared",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
@@ -425,6 +564,25 @@ export class BotProfileManager {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private createUpdateContext(): ProfileUpdateContext {
|
||||
return {
|
||||
generation: this.generation,
|
||||
channelGeneration: this.channelGeneration,
|
||||
clientId: this.tsClient.getClientId(),
|
||||
httpQuery: this.tsClient.getHttpQuery(),
|
||||
};
|
||||
}
|
||||
|
||||
private isCurrentUpdate(context: ProfileUpdateContext): boolean {
|
||||
return context.generation === this.generation &&
|
||||
context.clientId === this.tsClient.getClientId() &&
|
||||
context.httpQuery === this.tsClient.getHttpQuery();
|
||||
}
|
||||
|
||||
private isCurrentChannelUpdate(context: ProfileUpdateContext): boolean {
|
||||
return this.isCurrentUpdate(context) && context.channelGeneration === this.channelGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
|
||||
+116
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
|
||||
|
||||
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||
it("returns null for a plain search term", () => {
|
||||
@@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => {
|
||||
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
|
||||
});
|
||||
|
||||
// Issue #139: `!play id <id>` matches the "<command> <subcommand> <arg>"
|
||||
// shape of every other command. The colon form stays supported — users have
|
||||
// it in their chat scrollback and in older docs.
|
||||
it("parses the space-separated id form", () => {
|
||||
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
|
||||
expect(parseSongRef("ID 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
|
||||
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
|
||||
expect(parseSongRef("id 185868.")).toEqual({ id: "185868", platform: null });
|
||||
});
|
||||
|
||||
it("does not mistake a word merely starting with 'id' for an id reference", () => {
|
||||
expect(parseSongRef("idol")).toBeNull();
|
||||
expect(parseSongRef("identity 185868")).toBeNull();
|
||||
expect(parseSongRef("id")).toBeNull();
|
||||
expect(parseSongRef("id:")).toBeNull();
|
||||
// Two remaining tokens are a search phrase, not an id.
|
||||
expect(parseSongRef("id die for you")).toBeNull();
|
||||
});
|
||||
|
||||
// Without a colon, "id" is just a word — "ID 4" and "ID Bruno" are real track
|
||||
// titles. The space form therefore only claims tokens that could actually be
|
||||
// an id; everything else stays a search term.
|
||||
it("only treats the space form as an id when the token looks like one", () => {
|
||||
expect(parseSongRef("id Bruno")).toBeNull();
|
||||
expect(parseSongRef("id Marshmello")).toBeNull();
|
||||
expect(parseSongRef("id 4ever")).toBeNull();
|
||||
// …while every real id shape is still accepted.
|
||||
expect(parseSongRef("id 4")).toEqual({ id: "4", platform: null }); // numeric
|
||||
expect(parseSongRef("id BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: null });
|
||||
expect(parseSongRef("id 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); // QQ mid
|
||||
expect(parseSongRef("id a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toEqual({
|
||||
id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||
platform: null,
|
||||
}); // Jellyfin GUID / Kugou hash
|
||||
});
|
||||
|
||||
it("keeps the colon form unrestricted, so a short or odd id still works", () => {
|
||||
expect(parseSongRef("id:Bruno")).toEqual({ id: "Bruno", platform: null });
|
||||
expect(parseSongRef("id: 4ever")).toEqual({ id: "4ever", platform: null });
|
||||
});
|
||||
|
||||
it("does not let the space form swallow a pasted URL", () => {
|
||||
// `id <url>` used to fall through to the URL branches; it still must.
|
||||
expect(parseSongRef("id https://music.163.com/song?id=185868")).toEqual({
|
||||
id: "185868",
|
||||
platform: "netease",
|
||||
});
|
||||
expect(parseSongRef("id https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({
|
||||
id: "004Z8Ihr0JIu5s",
|
||||
platform: "qq",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
|
||||
// These reuse ?id= but are not songs — they should fall through to search,
|
||||
// not misresolve to getSongDetail(collectionId) and error "no song".
|
||||
@@ -67,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
|
||||
expect(parseSelectionIndex("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
|
||||
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
|
||||
expect(parsePlaylistRef("华语经典")).toBeNull();
|
||||
expect(parsePlaylistRef("2829883282")).toBeNull();
|
||||
expect(parsePlaylistRef("")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
});
|
||||
|
||||
it("does not mistake a NetEase userid= for the playlist id", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
|
||||
it("parses QQ Music playlist URLs", () => {
|
||||
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("parses YouTube playlist URLs by their list= id", () => {
|
||||
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
|
||||
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
|
||||
});
|
||||
|
||||
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
|
||||
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("finds the link inside an app's share text", () => {
|
||||
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findShareShortLink (#160)", () => {
|
||||
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
|
||||
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
|
||||
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
|
||||
});
|
||||
|
||||
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
|
||||
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
|
||||
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
|
||||
expect(findShareShortLink("华语经典")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShareLink (#160)", () => {
|
||||
it("returns the redirect target", async () => {
|
||||
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
|
||||
});
|
||||
|
||||
it("returns null when there is no redirect or the request fails", async () => {
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
|
||||
});
|
||||
});
|
||||
+114
-6
@@ -1,3 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
||||
* so same-name songs can be disambiguated instead of always getting the single
|
||||
@@ -18,9 +20,22 @@ export interface SongRef {
|
||||
platform: "netease" | "qq" | "bilibili" | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could this token plausibly BE an id on a supported platform?
|
||||
* - NetEase / Kugou numeric ids → all digits
|
||||
* - BiliBili → BV + 8-12 alphanumerics
|
||||
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
|
||||
* → 11+ chars from the id alphabet
|
||||
* Deliberately conservative: anything rejected here just stays an ordinary
|
||||
* search term, which is what it almost certainly was.
|
||||
*/
|
||||
function looksLikeSongId(token: string): boolean {
|
||||
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an explicit song reference in a query. Recognizes:
|
||||
* - `id:<id>` → platform from flags/default
|
||||
* - `id <id>` / `id:<id>` → platform from flags/default
|
||||
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
|
||||
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
|
||||
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
|
||||
@@ -30,11 +45,25 @@ export function parseSongRef(raw: string): SongRef | null {
|
||||
const q = (raw ?? "").trim();
|
||||
if (!q) return null;
|
||||
|
||||
// Explicit "id:<id>" — platform decided by the command's flags/default.
|
||||
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
|
||||
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
|
||||
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
|
||||
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
|
||||
// Explicit id — platform decided by the command's flags/default. The
|
||||
// separator is a colon or plain whitespace, so `id <id>` matches the
|
||||
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
|
||||
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
|
||||
// from a chat paste ("id:12345." / "id:12345)") — no supported id
|
||||
// (numeric / BVID / mid) ends in those.
|
||||
//
|
||||
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
|
||||
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
|
||||
// has to keep resolving as a URL. So the space form only claims tokens that
|
||||
// could actually be an id; anything else falls through to the URL branches
|
||||
// below and ultimately to a plain search.
|
||||
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
|
||||
if (idPrefix) {
|
||||
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
|
||||
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
|
||||
return { id, platform: null };
|
||||
}
|
||||
}
|
||||
|
||||
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
|
||||
// a "BV..." token never collides with them).
|
||||
@@ -71,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export interface PlaylistRef {
|
||||
id: string;
|
||||
platform: "netease" | "qq" | "youtube";
|
||||
}
|
||||
|
||||
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
|
||||
function stripUrlBBCode(text: string): string {
|
||||
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
|
||||
* share text. The platform comes from the URL, so a QQ link works without
|
||||
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
|
||||
* the caller handles as before.
|
||||
*/
|
||||
export function parsePlaylistRef(raw: string): PlaylistRef | null {
|
||||
const q = stripUrlBBCode(raw ?? "").trim();
|
||||
if (!q) return null;
|
||||
|
||||
if (/music\.163\.com/i.test(q)) {
|
||||
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "netease" };
|
||||
}
|
||||
|
||||
if (/y\.qq\.com/i.test(q)) {
|
||||
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "qq" };
|
||||
}
|
||||
|
||||
if (/youtube\.com|youtu\.be/i.test(q)) {
|
||||
const m = /[?&]list=([\w-]+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "youtube" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
|
||||
* short link — what the phone apps copy. Only these hosts are recognized so
|
||||
* the bot never fetches an arbitrary user-supplied URL.
|
||||
*/
|
||||
export function findShareShortLink(raw: string): string | null {
|
||||
const q = stripUrlBBCode(raw ?? "");
|
||||
const m =
|
||||
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
|
||||
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
|
||||
|
||||
const redirectGet: RedirectGet = async (url) => {
|
||||
const res = await axios.get(url, {
|
||||
maxRedirects: 0,
|
||||
timeout: 5000,
|
||||
validateStatus: () => true,
|
||||
responseType: "stream",
|
||||
});
|
||||
res.data?.destroy?.();
|
||||
const location = res.headers.location;
|
||||
return { status: res.status, location: typeof location === "string" ? location : undefined };
|
||||
};
|
||||
|
||||
/** Follow a share short link one hop. Returns the target URL, or null. */
|
||||
export async function resolveShareLink(
|
||||
url: string,
|
||||
get: RedirectGet = redirectGet,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { status, location } = await get(url);
|
||||
if (status < 300 || status >= 400 || !location) return null;
|
||||
return new URL(location, url).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Server } from "node:http";
|
||||
import { closeEmbeddedApi, getSafeApiStartupError, startEmbeddedApi, type ApiChildMessage, type ApiProvider } from "./api-server-runtime.js";
|
||||
|
||||
const providerArg = process.argv[2];
|
||||
const port = Number(process.argv[3]);
|
||||
if ((providerArg !== "netease" && providerArg !== "qq") || !Number.isInteger(port) || port < 1 || port > 65535 || !process.send) process.exit(1);
|
||||
const provider = providerArg as ApiProvider;
|
||||
let server: Server | null = null;
|
||||
let stopping = false;
|
||||
|
||||
function shutdown(exitCode = 0): void {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
// Also covers a legacy auto-start listener and an import still in flight.
|
||||
const deadline = setTimeout(() => process.exit(exitCode), 1000);
|
||||
closeEmbeddedApi(server).finally(() => { clearTimeout(deadline); process.exit(exitCode); });
|
||||
}
|
||||
|
||||
function fail(error: unknown): void {
|
||||
if (stopping) return;
|
||||
const message: ApiChildMessage = { type: "error", provider, port, ...getSafeApiStartupError(error) };
|
||||
if (!process.connected) { shutdown(1); return; }
|
||||
try { process.send!(message, () => shutdown(1)); }
|
||||
catch { shutdown(1); }
|
||||
}
|
||||
|
||||
process.on("message", (message: unknown) => {
|
||||
if (message && typeof message === "object" && (message as { type?: unknown }).type === "stop") shutdown();
|
||||
});
|
||||
process.on("disconnect", () => shutdown());
|
||||
process.on("SIGTERM", () => shutdown());
|
||||
process.on("SIGINT", () => shutdown());
|
||||
process.on("uncaughtException", fail);
|
||||
process.on("unhandledRejection", fail);
|
||||
|
||||
startEmbeddedApi(provider, port).then((runtime) => {
|
||||
server = runtime.server;
|
||||
if (stopping || !process.connected) { shutdown(); return; }
|
||||
server?.on("error", fail);
|
||||
const message: ApiChildMessage = { type: "ready", provider, port };
|
||||
try { process.send!(message, (error) => { if (error) shutdown(1); }); }
|
||||
catch { shutdown(1); }
|
||||
}).catch(fail);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { closeEmbeddedApi, getSafeApiStartupError, startEmbeddedApi } from "./api-server-runtime.js";
|
||||
|
||||
const state = vi.hoisted(() => ({ serveNcmApi: vi.fn(), qqExport: null as any, portFree: true }));
|
||||
vi.mock("NeteaseCloudMusicApi", () => ({ server: { serveNcmApi: state.serveNcmApi } }));
|
||||
vi.mock("@sansenjian/qq-music-api", () => ({ get default() { return state.qqExport; } }));
|
||||
vi.mock("node:net", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
return { default: { createServer: () => {
|
||||
const probe = new EventEmitter() as EventEmitter & { close(done: () => void): void; listen(): void };
|
||||
probe.close = (done) => queueMicrotask(done);
|
||||
probe.listen = () => queueMicrotask(() => probe.emit(state.portFree ? "listening" : "error"));
|
||||
return probe;
|
||||
} } };
|
||||
});
|
||||
|
||||
class FakeServer extends EventEmitter {
|
||||
listening = false;
|
||||
close = vi.fn((done: () => void) => { this.listening = false; queueMicrotask(done); return this; });
|
||||
closeAllConnections = vi.fn();
|
||||
}
|
||||
|
||||
describe("embedded API runtime", () => {
|
||||
let server: FakeServer;
|
||||
let listen: ReturnType<typeof vi.fn>;
|
||||
let previousPort: string | undefined;
|
||||
beforeEach(() => {
|
||||
previousPort = process.env.PORT;
|
||||
server = new FakeServer();
|
||||
state.portFree = true;
|
||||
state.serveNcmApi.mockReset();
|
||||
state.serveNcmApi.mockResolvedValue({ server });
|
||||
listen = vi.fn(() => { queueMicrotask(() => { server.listening = true; server.emit("listening"); }); return server; });
|
||||
state.qqExport = { listen };
|
||||
});
|
||||
afterEach(() => { if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort; });
|
||||
|
||||
it("binds NetEase to loopback/configured port without version checks and waits for listening", async () => {
|
||||
let ready = false;
|
||||
const starting = startEmbeddedApi("netease", 39218).then((result) => { ready = true; return result; });
|
||||
await vi.waitFor(() => expect(server.listenerCount("listening")).toBe(1));
|
||||
expect(state.serveNcmApi).toHaveBeenCalledWith({ port: 39218, host: "127.0.0.1", checkVersion: false });
|
||||
expect(ready).toBe(false);
|
||||
server.listening = true; server.emit("listening");
|
||||
expect((await starting).server).toBe(server);
|
||||
});
|
||||
it("closes the HTTP server returned by NetEase rather than the Express app", async () => {
|
||||
server.listening = true;
|
||||
const runtime = await startEmbeddedApi("netease", 39218);
|
||||
await closeEmbeddedApi(runtime.server);
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
expect(server.closeAllConnections).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("rejects failed listening and cleans up the startup handle", async () => {
|
||||
const starting = startEmbeddedApi("netease", 39218);
|
||||
const rejected = expect(starting).rejects.toMatchObject({ code: "EADDRINUSE" });
|
||||
await vi.waitFor(() => expect(server.listenerCount("error")).toBe(1));
|
||||
server.emit("error", Object.assign(new Error("bind failure"), { code: "EADDRINUSE" }));
|
||||
await rejected;
|
||||
expect(server.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("binds QQ to its configured loopback port and restores injected PORT", async () => {
|
||||
process.env.PORT = "39999";
|
||||
expect((await startEmbeddedApi("qq", 39217)).server).toBe(server);
|
||||
expect(listen).toHaveBeenCalledWith(39217, "127.0.0.1");
|
||||
expect(process.env.PORT).toBe("39999");
|
||||
});
|
||||
it("restores an absent PORT and supports the legacy nested export", async () => {
|
||||
delete process.env.PORT; state.qqExport = { default: { listen } };
|
||||
await startEmbeddedApi("qq", 39217);
|
||||
expect(process.env.PORT).toBeUndefined();
|
||||
expect(listen).toHaveBeenCalledWith(39217, "127.0.0.1");
|
||||
});
|
||||
it("reuses a legacy module that auto-started on import without a duplicate listen", async () => {
|
||||
state.portFree = false;
|
||||
expect(await startEmbeddedApi("qq", 39217)).toEqual({ server: null });
|
||||
expect(listen).not.toHaveBeenCalled();
|
||||
});
|
||||
it("never reflects arbitrary startup message, stack or code values", () => {
|
||||
expect(getSafeApiStartupError({ message: "synthetic-credential", stack: "synthetic-credential", code: "synthetic-credential" })).toEqual({ category: "startup" });
|
||||
expect(getSafeApiStartupError({ code: "ERR_REQUIRE_ESM", message: "synthetic-credential" })).toEqual({ category: "esm", code: "ERR_REQUIRE_ESM" });
|
||||
expect(getSafeApiStartupError({ code: "EBADENGINE" })).toEqual({ category: "node-engine", code: "EBADENGINE" });
|
||||
expect(getSafeApiStartupError({ code: "EADDRINUSE" })).toEqual({ category: "port-in-use", code: "EADDRINUSE" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import net from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
export type ApiProvider = "netease" | "qq";
|
||||
export type ApiStartupCategory = "esm" | "node-engine" | "port-in-use" | "startup" | "timeout" | "cancelled";
|
||||
export interface SafeApiStartupError { category: ApiStartupCategory; code?: string }
|
||||
export type ApiChildMessage =
|
||||
| { type: "ready"; provider: ApiProvider; port: number }
|
||||
| ({ type: "error"; provider: ApiProvider; port: number } & SafeApiStartupError);
|
||||
|
||||
const SAFE_ERROR_CODES = new Set(["ERR_REQUIRE_ESM", "EBADENGINE", "EADDRINUSE", "EACCES", "ENOENT", "MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND"]);
|
||||
export function safeApiErrorCode(code: unknown): string | undefined {
|
||||
return typeof code === "string" && SAFE_ERROR_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
/** Classification may inspect a message locally, but IPC never contains it. */
|
||||
export function getSafeApiStartupError(err: unknown): SafeApiStartupError {
|
||||
const error = (err ?? {}) as { code?: unknown; message?: unknown };
|
||||
const code = safeApiErrorCode(error.code);
|
||||
const message = typeof error.message === "string" ? error.message : "";
|
||||
let category: ApiStartupCategory = "startup";
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(message)) category = "esm";
|
||||
else if (code === "EBADENGINE" || /Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(message)) category = "node-engine";
|
||||
else if (code === "EADDRINUSE") category = "port-in-use";
|
||||
return code ? { category, code } : { category };
|
||||
}
|
||||
|
||||
export function isApiPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => server.close(() => resolve(false)));
|
||||
server.once("listening", () => server.close(() => resolve(true)));
|
||||
server.listen(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function waitForListening(server: Server): Promise<void> {
|
||||
if (server.listening) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const ready = () => { cleanup(); resolve(); };
|
||||
const failed = (error: Error) => { cleanup(); reject(error); };
|
||||
const cleanup = () => { server.off("listening", ready); server.off("error", failed); };
|
||||
server.once("listening", ready);
|
||||
server.once("error", failed);
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeEmbeddedApi(server: Server | null): Promise<void> {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
server.close(() => resolve());
|
||||
server.closeAllConnections?.();
|
||||
} catch { resolve(); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Only call in the isolated child: these dependencies write raw request URLs
|
||||
* and response cookies directly to console, outside the bot's logger. */
|
||||
export async function startEmbeddedApi(provider: ApiProvider, port: number): Promise<{ server: Server | null }> {
|
||||
let server: Server | null = null;
|
||||
try {
|
||||
if (provider === "netease") {
|
||||
const imported = await import("NeteaseCloudMusicApi") as any;
|
||||
const api = imported.server ?? imported.default?.server;
|
||||
const app = await api.serveNcmApi({ port, host: "127.0.0.1", checkVersion: false });
|
||||
server = app.server;
|
||||
if (!server) throw new Error("NetEase API did not expose its HTTP server");
|
||||
} else {
|
||||
const previousPort = process.env.PORT;
|
||||
process.env.PORT = String(port);
|
||||
let imported: any;
|
||||
try { imported = await import("@sansenjian/qq-music-api"); }
|
||||
finally {
|
||||
if (previousPort === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = previousPort;
|
||||
}
|
||||
const candidate = imported.default ?? imported;
|
||||
const app = typeof candidate.listen === "function" ? candidate : candidate.default;
|
||||
if (!app || typeof app.listen !== "function") throw new Error("QQ API did not expose a Koa app");
|
||||
// Historical packages listened during import. Their listener remains
|
||||
// owned by this child and closes when the child exits.
|
||||
if (!(await isApiPortFree(port))) return { server: null };
|
||||
server = app.listen(port, "127.0.0.1");
|
||||
}
|
||||
await waitForListening(server!);
|
||||
return { server };
|
||||
} catch (error) {
|
||||
await closeEmbeddedApi(server);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+170
-112
@@ -1,133 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
// Record every listen() the QQ sidecar makes so we can assert it is always
|
||||
// pinned to the configured port (regression coverage for issue #122).
|
||||
const mockState = vi.hoisted(() => ({
|
||||
listenCalls: [] as Array<{ port: number; host: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@sansenjian/qq-music-api", () => {
|
||||
const app = {
|
||||
listen(port: number, host: string, cb?: () => void) {
|
||||
mockState.listenCalls.push({ port, host });
|
||||
const server = {
|
||||
address: () => ({ port, address: host, family: "IPv4" as const }),
|
||||
on() {
|
||||
return server;
|
||||
},
|
||||
close(done?: () => void) {
|
||||
done?.();
|
||||
},
|
||||
const state = vi.hoisted(() => ({ fork: vi.fn(), probes: [] as EventEmitter[], probeAutomatically: true, portFree: true, directImports: 0 }));
|
||||
vi.mock("node:child_process", () => ({ fork: state.fork }));
|
||||
vi.mock("node:net", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
return { default: { createServer: () => {
|
||||
const probe = new EventEmitter() as EventEmitter & { close(done: () => void): void; listen(): void };
|
||||
probe.close = (done) => queueMicrotask(done);
|
||||
probe.listen = () => {
|
||||
state.probes.push(probe);
|
||||
if (state.probeAutomatically) queueMicrotask(() => probe.emit(state.portFree ? "listening" : "error"));
|
||||
};
|
||||
// Real net/Koa fire the listening callback on a later tick, after the
|
||||
// caller has captured the returned server handle.
|
||||
if (cb) setImmediate(cb);
|
||||
return server;
|
||||
},
|
||||
};
|
||||
return { default: app };
|
||||
return probe;
|
||||
} } };
|
||||
});
|
||||
vi.mock("@sansenjian/qq-music-api", () => {
|
||||
state.directImports++;
|
||||
return { default: { listen: () => { throw new Error("sidecar imported in parent"); } } };
|
||||
});
|
||||
vi.mock("NeteaseCloudMusicApi", () => {
|
||||
state.directImports++;
|
||||
return { server: { serveNcmApi: () => { throw new Error("sidecar imported in parent"); } } };
|
||||
});
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
connected = true;
|
||||
exitOnStop = true;
|
||||
send = vi.fn((message: { type: string }) => {
|
||||
if (message.type === "stop" && this.exitOnStop) queueMicrotask(() => this.finish(0, null));
|
||||
return true;
|
||||
});
|
||||
kill = vi.fn((signal: string = "SIGTERM") => { queueMicrotask(() => this.finish(null, signal)); return true; });
|
||||
finish(code: number | null, signal: string | null) { this.connected = false; this.emit("exit", code, signal); }
|
||||
}
|
||||
|
||||
describe("describeQqApiStartupError", () => {
|
||||
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
||||
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
|
||||
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
|
||||
expect(hint).toMatch(/~2\.4\.0/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
it("retains ESM diagnostics by code and message", () => {
|
||||
expect(describeQqApiStartupError({ code: "ERR_REQUIRE_ESM" })).toMatch(/~2\.4\.0/);
|
||||
expect(describeQqApiStartupError(new Error("require() of ES Module is unsupported"))).toMatch(/ERR_REQUIRE_ESM/);
|
||||
});
|
||||
|
||||
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
|
||||
const hint = describeQqApiStartupError(
|
||||
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
|
||||
);
|
||||
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
|
||||
});
|
||||
|
||||
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
|
||||
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
|
||||
expect(hint).toMatch(/Node >=20\.17/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
|
||||
expect(describeQqApiStartupError(undefined)).toBeNull();
|
||||
expect(describeQqApiStartupError(null)).toBeNull();
|
||||
it("retains engine diagnostics and ignores unrelated failures", () => {
|
||||
expect(describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"))).toMatch(/Node >=20\.17/);
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
|
||||
// the same port the client base URL targets (config.qqMusicApiPort). A stale
|
||||
// build once bound 3300 while the client requested 3200, silently breaking the
|
||||
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
|
||||
describe("createApiServerManager — QQ sidecar port binding", () => {
|
||||
const noopLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
trace() {},
|
||||
fatal() {},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("embedded API child lifecycle", () => {
|
||||
let children: FakeChild[];
|
||||
let logger: Logger;
|
||||
let manager: ReturnType<typeof createApiServerManager>;
|
||||
let automaticReady: boolean;
|
||||
const options = { neteasePort: 39218, qqMusicPort: 39217, neteaseEnabled: true, qqEnabled: true };
|
||||
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
||||
beforeEach(() => {
|
||||
mockState.listenCalls = [];
|
||||
vi.useRealTimers(); children = []; automaticReady = true;
|
||||
state.probes = []; state.portFree = true; state.probeAutomatically = true; state.fork.mockReset();
|
||||
state.fork.mockImplementation((_entry: string, args: string[]) => {
|
||||
const child = new FakeChild(); children.push(child);
|
||||
if (automaticReady) queueMicrotask(() => child.emit("message", { type: "ready", provider: args[0], port: Number(args[1]) }));
|
||||
return child as unknown as ChildProcess;
|
||||
});
|
||||
logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger;
|
||||
manager = createApiServerManager(options, logger);
|
||||
});
|
||||
afterEach(async () => { manager.stop(); await flush(); vi.useRealTimers(); });
|
||||
|
||||
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
|
||||
const port = 39217; // uncommon port to avoid clashing with a real instance
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
it("isolates both APIs with ignored stdio, configured ports and IPC", async () => {
|
||||
await manager.start();
|
||||
manager.stop();
|
||||
|
||||
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
expect(state.fork.mock.calls.map((call) => call[1])).toEqual([["netease", "39218"], ["qq", "39217"]]);
|
||||
for (const call of state.fork.mock.calls) {
|
||||
expect(String(call[0])).toMatch(/api-server-child\.ts$/);
|
||||
expect(call[2].stdio).toEqual(["ignore", "ignore", "ignore", "ipc"]);
|
||||
expect(call[2].execArgv).not.toContain("--eval");
|
||||
expect(call[2].execArgv).not.toContain("--input-type=module");
|
||||
}
|
||||
expect(state.directImports).toBe(0);
|
||||
expect(manager.getNeteaseBaseUrl()).toBe("http://127.0.0.1:39218");
|
||||
expect(manager.getQQMusicBaseUrl()).toBe("http://127.0.0.1:39217");
|
||||
});
|
||||
|
||||
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
|
||||
const port = 39219;
|
||||
const previous = process.env.PORT;
|
||||
// Simulate a hosting platform / compose file injecting a stray PORT that
|
||||
// must NOT leak into the QQ sidecar's chosen port.
|
||||
process.env.PORT = "39999";
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
it("preserves provider gating and externally bound port reuse", async () => {
|
||||
manager = createApiServerManager({ ...options, neteaseEnabled: false, qqEnabled: false }, logger);
|
||||
await manager.start(); expect(state.probes).toHaveLength(0); expect(state.fork).not.toHaveBeenCalled();
|
||||
state.portFree = false;
|
||||
manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
await manager.start(); expect(state.fork).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, expect.stringContaining("reusing"));
|
||||
});
|
||||
it("inherits tsx loader arguments without unrelated parent runner flags", async () => {
|
||||
const previous = process.execArgv;
|
||||
process.execArgv = ["--require", "C:\\app\\node_modules\\tsx\\dist\\preflight.cjs", "--import", "file:///app/node_modules/tsx/dist/loader.mjs", "--eval", "synthetic-evaluation", "--conditions", "vitest", "--input-type=module", "--inspect"];
|
||||
try {
|
||||
await manager.start();
|
||||
// The sidecar follows qqMusicPort, never the injected PORT.
|
||||
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||
// The injected PORT is restored so nothing else in the process is affected.
|
||||
expect(process.env.PORT).toBe("39999");
|
||||
} finally {
|
||||
manager.stop();
|
||||
if (previous === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an absent PORT env unset after importing the sidecar", async () => {
|
||||
const port = 39221;
|
||||
const previous = process.env.PORT;
|
||||
delete process.env.PORT;
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
try {
|
||||
await manager.start();
|
||||
// Was unset before importing — must be unset again, no leaked override.
|
||||
expect(process.env.PORT).toBeUndefined();
|
||||
} finally {
|
||||
manager.stop();
|
||||
if (previous === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = previous;
|
||||
expect(state.fork.mock.calls[0][2].execArgv).toEqual(process.execArgv.slice(0, 4));
|
||||
} finally { process.execArgv = previous; }
|
||||
});
|
||||
it("does not duplicate concurrent or repeated starts", async () => {
|
||||
await Promise.all([manager.start(), manager.start()]); await manager.start();
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("fences a stop during pending port preflight", async () => {
|
||||
state.probeAutomatically = false;
|
||||
const starting = manager.start(); await flush(); manager.stop();
|
||||
state.probes[0].emit("listening"); await starting;
|
||||
expect(state.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
it("cancels a pending handshake and ignores its late ready", async () => {
|
||||
automaticReady = false;
|
||||
const starting = manager.start(); await flush(); expect(children).toHaveLength(1);
|
||||
manager.stop(); children[0].emit("message", { type: "ready", provider: "netease", port: 39218 }); await starting;
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
expect(state.fork).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).not.toHaveBeenCalledWith({ port: 39218 }, "NetEase Cloud Music API started");
|
||||
});
|
||||
it("waits for a cancelled preflight to release its probe before restart", async () => {
|
||||
state.probeAutomatically = false;
|
||||
const first = manager.start(); await flush(); manager.stop();
|
||||
const restarting = manager.start(); await flush();
|
||||
expect(state.probes).toHaveLength(1);
|
||||
state.probeAutomatically = true; state.probes[0].emit("listening");
|
||||
await Promise.all([first, restarting]);
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("waits for old children to exit before restart", async () => {
|
||||
await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
|
||||
const restarting = manager.start(); await flush(); expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
children.slice(0, 2).forEach((child) => child.finish(0, null)); await restarting;
|
||||
expect(state.fork).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it("reports unexpected post-ready exits with safe fields", async () => {
|
||||
await manager.start(); children[1].finish(7, "SIGTERM");
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, code: 7, signal: "SIGTERM" }, expect.stringContaining("exited unexpectedly"));
|
||||
});
|
||||
it("retains static QQ diagnostics and discards arbitrary IPC fields", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("message", { type: "error", provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM", message: "synthetic-credential", stack: "synthetic-credential" }); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM" }, expect.stringContaining("ERR_REQUIRE_ESM"));
|
||||
expect(JSON.stringify([...(logger.error as ReturnType<typeof vi.fn>).mock.calls, ...(logger.warn as ReturnType<typeof vi.fn>).mock.calls])).not.toContain("synthetic-credential");
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
});
|
||||
it("ignores a ready message for a different provider or port", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("message", { type: "ready", provider: "netease", port: 39217 });
|
||||
children[0].emit("message", { type: "ready", provider: "qq", port: 39999 });
|
||||
await flush();
|
||||
expect(logger.info).not.toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
|
||||
children[0].emit("message", { type: "ready", provider: "qq", port: 39217 }); await starting;
|
||||
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
|
||||
});
|
||||
it("cleans up a failed fork that closes without an exit event", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("error", Object.assign(new Error("synthetic-credential"), { code: "ENOENT" }));
|
||||
children[0].emit("close", null, null); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "startup", code: "ENOENT" }, expect.stringContaining("start"));
|
||||
automaticReady = true; await manager.start();
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("times out and terminates a silent child", async () => {
|
||||
vi.useFakeTimers(); automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush(); await vi.advanceTimersByTimeAsync(30000); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "timeout" }, expect.stringContaining("start"));
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
});
|
||||
it("forces shutdown if a child ignores stop", async () => {
|
||||
vi.useFakeTimers(); await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(children.every((child) => child.kill.mock.calls.length > 0)).toBe(true);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
it("escalates to SIGKILL if stop and SIGTERM are ignored", async () => {
|
||||
vi.useFakeTimers(); await manager.start();
|
||||
for (const child of children) {
|
||||
child.exitOnStop = false;
|
||||
child.kill.mockImplementation((signal: string = "SIGTERM") => {
|
||||
if (signal === "SIGKILL") queueMicrotask(() => child.finish(null, signal));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
manager.stop(); await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(children.every((child) => child.kill.mock.calls.some(([signal]) => signal === "SIGKILL"))).toBe(true);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+168
-186
@@ -1,16 +1,13 @@
|
||||
import net from "node:net";
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { Server } from "node:http";
|
||||
import { getSafeApiStartupError, isApiPortFree, safeApiErrorCode, type ApiProvider, type SafeApiStartupError } from "./api-server-runtime.js";
|
||||
|
||||
export interface ApiServerOptions {
|
||||
neteasePort: number;
|
||||
qqMusicPort: number;
|
||||
/** Provider gating (#enabledProviders): when false, the corresponding
|
||||
* embedded sidecar API server is never started and its port never bound. */
|
||||
neteaseEnabled?: boolean;
|
||||
qqEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiServerManager {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
@@ -18,196 +15,181 @@ export interface ApiServerManager {
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
|
||||
* actionable operator guidance, or null when it isn't a recognised
|
||||
* dependency/runtime mismatch. Exported for testing.
|
||||
*
|
||||
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
|
||||
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
|
||||
* that needs Node >=20.17 — either way the embedded server never binds, so
|
||||
* every QQ request fails downstream with ECONNREFUSED on the API port.
|
||||
*/
|
||||
export function describeQqApiStartupError(err: unknown): string | null {
|
||||
const e = (err ?? {}) as { code?: string; message?: string };
|
||||
const code = String(e.code ?? "");
|
||||
const msg = String(e.message ?? "");
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
|
||||
return (
|
||||
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
|
||||
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
|
||||
);
|
||||
}
|
||||
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
|
||||
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
}
|
||||
const { category } = getSafeApiStartupError(err);
|
||||
if (category === "esm") return "an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall";
|
||||
if (category === "node-engine") return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => {
|
||||
server.close(() => resolve(false));
|
||||
});
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, "127.0.0.1");
|
||||
});
|
||||
/** Carry only tsx loader arguments into a source child. CLI evaluation,
|
||||
* inspector and test-runner flags have unrelated meanings in a fork. */
|
||||
function childExecArgv(source: boolean): string[] {
|
||||
if (!source) return [];
|
||||
const args: string[] = [];
|
||||
for (let i = 0; i < process.execArgv.length; i++) {
|
||||
const arg = process.execArgv[i];
|
||||
if (arg === "--import" || arg === "--require" || arg === "-r") {
|
||||
const value = process.execArgv[++i];
|
||||
if (value && (value === "tsx" || /[/\\]tsx[/\\]/.test(value))) args.push(arg, value);
|
||||
} else if (arg.startsWith("--import=") && (arg === "--import=tsx" || /[/\\]tsx[/\\]/.test(arg))) args.push(arg);
|
||||
}
|
||||
return args.length ? args : ["--import", "tsx"];
|
||||
}
|
||||
|
||||
export function createApiServerManager(
|
||||
options: ApiServerOptions,
|
||||
logger: Logger
|
||||
): ApiServerManager {
|
||||
let neteaseServer: Server | null = null;
|
||||
let qqMusicServer: Server | null = null;
|
||||
|
||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
// Provider gating: with the jellyfin-only default config neither legacy
|
||||
// sidecar starts, so ports 3001/3200 are never opened.
|
||||
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
|
||||
return;
|
||||
class StartupFailure extends Error {
|
||||
constructor(readonly details: SafeApiStartupError) { super("Embedded music API startup failed"); }
|
||||
}
|
||||
logger.info("Starting embedded music API servers...");
|
||||
interface ManagedChild {
|
||||
child: ChildProcess;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
const STARTUP_TIMEOUT_MS = 15000;
|
||||
const ERROR_CATEGORIES = new Set(["esm", "node-engine", "port-in-use", "startup"]);
|
||||
|
||||
// Start NetEase Cloud Music API
|
||||
if (options.neteaseEnabled !== false) {
|
||||
export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
|
||||
const children = new Map<ApiProvider, ManagedChild>();
|
||||
let generation = 0;
|
||||
let starting: Promise<void> | null = null;
|
||||
let stopping: Promise<void> = Promise.resolve();
|
||||
|
||||
function launch(provider: ApiProvider, port: number, launchGeneration: number): Promise<void> {
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
const entry = new URL(source ? "./api-server-child.ts" : "./api-server-child.js", import.meta.url);
|
||||
const child = fork(entry, [provider, String(port)], {
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
execArgv: childExecArgv(source),
|
||||
});
|
||||
let ready = false;
|
||||
let expectedExit = false;
|
||||
let settled = false;
|
||||
let hasExited = false;
|
||||
let startupTimer: ReturnType<typeof setTimeout>;
|
||||
let terminateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let resolveExit!: () => void;
|
||||
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
||||
let resolveStart!: () => void;
|
||||
let rejectStart!: (error: StartupFailure) => void;
|
||||
const started = new Promise<void>((resolve, reject) => { resolveStart = resolve; rejectStart = reject; });
|
||||
const settle = (error?: SafeApiStartupError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(startupTimer);
|
||||
if (error) rejectStart(new StartupFailure(error)); else resolveStart();
|
||||
};
|
||||
const record: ManagedChild = {
|
||||
child,
|
||||
stop() {
|
||||
if (expectedExit) return exited;
|
||||
expectedExit = true;
|
||||
settle({ category: "cancelled" });
|
||||
if (children.get(provider) === record) children.delete(provider);
|
||||
stopping = Promise.all([stopping, exited]).then(() => {});
|
||||
if (hasExited) return exited;
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
}
|
||||
|
||||
// Start QQ Music API. Older versions auto-started on import; the
|
||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||
// so we explicitly call .listen() on the imported Koa app and keep
|
||||
// the server handle for clean shutdown.
|
||||
if (options.qqEnabled === false) return;
|
||||
try {
|
||||
const portFree = await isPortFree(options.qqMusicPort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
// Pin the upstream server to the configured port before importing.
|
||||
// The package derives its default port from process.env.PORT (falling
|
||||
// back to 3200) and, in some historical versions, auto-started that
|
||||
// server as an import side effect. Aligning PORT with qqMusicApiPort
|
||||
// guarantees the sidecar can never bind a different port than the one
|
||||
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
|
||||
// issue #122, where an old build listened on 3300 while the client
|
||||
// requested 3200. Restore the previous value right after import so we
|
||||
// never leak the override into the rest of the process (e.g. the web
|
||||
// server or the NetEase sidecar, which also read PORT as a fallback).
|
||||
const prevPortEnv = process.env.PORT;
|
||||
process.env.PORT = String(options.qqMusicPort);
|
||||
let qqModule: any;
|
||||
try {
|
||||
qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||
} finally {
|
||||
if (prevPortEnv === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = prevPortEnv;
|
||||
}
|
||||
// The module's export structure varies between versions:
|
||||
// 2.2.11+: default → Koa app (has .listen)
|
||||
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||
// older: module itself may be the Koa app
|
||||
const candidate = qqModule.default ?? qqModule;
|
||||
const koaApp = typeof candidate.listen === "function"
|
||||
? candidate
|
||||
: candidate.default ?? null;
|
||||
if (koaApp && typeof koaApp.listen === "function") {
|
||||
// A version that auto-started on import has already bound the
|
||||
// configured port (thanks to the PORT alignment above); reuse it
|
||||
// rather than racing a second listen that would fail EADDRINUSE.
|
||||
const stillFree = await isPortFree(options.qqMusicPort);
|
||||
if (!stillFree) {
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
|
||||
);
|
||||
} else {
|
||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||
resolve(srv)
|
||||
);
|
||||
srv.on("error", reject);
|
||||
});
|
||||
// Log the port actually bound (read from the socket) rather than
|
||||
// the requested one, so operators can spot a mismatch in the logs.
|
||||
const addr = qqMusicServer.address();
|
||||
const boundPort =
|
||||
addr && typeof addr === "object" && addr !== null
|
||||
? addr.port
|
||||
: options.qqMusicPort;
|
||||
logger.info(
|
||||
{ port: boundPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn("QQ Music API module does not expose a Koa app");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const hint = describeQqApiStartupError(err);
|
||||
if (hint) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
logger.info("Stopping music API servers");
|
||||
if (neteaseServer && typeof (neteaseServer as any).close === "function") {
|
||||
(neteaseServer as any).close();
|
||||
}
|
||||
neteaseServer = null;
|
||||
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
|
||||
(qqMusicServer as any).close();
|
||||
}
|
||||
qqMusicServer = null;
|
||||
},
|
||||
|
||||
getNeteaseBaseUrl(): string {
|
||||
return neteaseBaseUrl;
|
||||
},
|
||||
|
||||
getQQMusicBaseUrl(): string {
|
||||
return qqMusicBaseUrl;
|
||||
if (child.connected) child.send({ type: "stop" }, (error) => { if (error) child.kill("SIGTERM"); });
|
||||
else child.kill("SIGTERM");
|
||||
} catch { child.kill("SIGTERM"); }
|
||||
terminateTimer = setTimeout(() => child.kill("SIGTERM"), 1000);
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
|
||||
terminateTimer.unref(); killTimer.unref();
|
||||
return exited;
|
||||
},
|
||||
};
|
||||
children.set(provider, record);
|
||||
child.on("message", (message: unknown) => {
|
||||
if (!message || typeof message !== "object" || expectedExit || launchGeneration !== generation) return;
|
||||
const data = message as Record<string, unknown>;
|
||||
if (data.provider !== provider || data.port !== port) return;
|
||||
if (data.type === "ready") { ready = true; settle(); }
|
||||
else if (data.type === "error" && typeof data.category === "string" && ERROR_CATEGORIES.has(data.category)) {
|
||||
const code = safeApiErrorCode(data.code);
|
||||
const details: SafeApiStartupError = { category: data.category as SafeApiStartupError["category"], ...(code ? { code } : {}) };
|
||||
if (!ready) settle(details);
|
||||
else logger.error({ provider, port, ...details }, "Embedded music API reported a runtime failure");
|
||||
void record.stop();
|
||||
}
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (expectedExit) return;
|
||||
const details = getSafeApiStartupError(error);
|
||||
if (!ready) settle(details);
|
||||
else logger.error({ provider, port, ...details }, "Embedded music API child failed");
|
||||
void record.stop();
|
||||
});
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (hasExited) return;
|
||||
hasExited = true;
|
||||
clearTimeout(startupTimer); clearTimeout(terminateTimer); clearTimeout(killTimer);
|
||||
if (children.get(provider) === record) children.delete(provider);
|
||||
if (!expectedExit) {
|
||||
if (ready) logger.error({ provider, port, code, signal }, "Embedded music API exited unexpectedly");
|
||||
else settle({ category: "startup" });
|
||||
}
|
||||
resolveExit();
|
||||
};
|
||||
child.once("exit", onExit);
|
||||
// A failed fork emits close without exit.
|
||||
child.once("close", onExit);
|
||||
startupTimer = setTimeout(() => { settle({ category: "timeout" }); void record.stop(); }, STARTUP_TIMEOUT_MS);
|
||||
return started;
|
||||
}
|
||||
|
||||
return {
|
||||
start(): Promise<void> {
|
||||
if (starting) return starting;
|
||||
const startGeneration = generation;
|
||||
const run = async () => {
|
||||
await stopping;
|
||||
if (startGeneration !== generation) return;
|
||||
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||
logger.info("NetEase/QQ providers disabled — embedded music API servers not started"); return;
|
||||
}
|
||||
logger.info("Starting embedded music API servers...");
|
||||
const providers: Array<{ provider: ApiProvider; port: number; enabled: boolean; name: string }> = [
|
||||
{ provider: "netease", port: options.neteasePort, enabled: options.neteaseEnabled !== false, name: "NetEase Cloud Music" },
|
||||
{ provider: "qq", port: options.qqMusicPort, enabled: options.qqEnabled !== false, name: "QQ Music" },
|
||||
];
|
||||
for (const { provider, port, enabled, name } of providers) {
|
||||
if (startGeneration !== generation) return;
|
||||
if (!enabled || children.has(provider)) continue;
|
||||
try {
|
||||
const free = await isApiPortFree(port);
|
||||
if (startGeneration !== generation) return;
|
||||
if (!free) {
|
||||
logger.info({ port }, `${provider === "netease" ? "NetEase" : "QQ Music"} API port already in use — reusing existing instance`);
|
||||
continue;
|
||||
}
|
||||
await launch(provider, port, startGeneration);
|
||||
if (startGeneration !== generation) return;
|
||||
logger.info({ port }, `${name} API started`);
|
||||
} catch (error) {
|
||||
if (startGeneration !== generation) return;
|
||||
const details = error instanceof StartupFailure ? error.details : getSafeApiStartupError(error);
|
||||
if (details.category === "cancelled") return;
|
||||
const hint = provider === "qq" ? describeQqApiStartupError(details.category === "esm" ? { code: "ERR_REQUIRE_ESM" } : details.category === "node-engine" ? { code: "EBADENGINE" } : {}) : null;
|
||||
logger.error({ provider, port, ...details }, hint ? `QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${port} is down.` : `Failed to start ${name} API`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const promise = run();
|
||||
starting = promise;
|
||||
void promise.finally(() => { if (starting === promise) starting = null; });
|
||||
return promise;
|
||||
},
|
||||
stop(): void {
|
||||
generation++;
|
||||
const pendingStart = starting;
|
||||
starting = null;
|
||||
logger.info("Stopping music API servers");
|
||||
const retiring = [...children.values()];
|
||||
children.clear();
|
||||
// A cancelled preflight still owns a temporary listening socket until
|
||||
// its callback closes it. Restart must wait for that work as well.
|
||||
stopping = Promise.all([stopping, pendingStart, ...retiring.map((record) => record.stop())]).then(() => {});
|
||||
},
|
||||
getNeteaseBaseUrl: () => `http://127.0.0.1:${options.neteasePort}`,
|
||||
getQQMusicBaseUrl: () => `http://127.0.0.1:${options.qqMusicPort}`,
|
||||
};
|
||||
}
|
||||
+167
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
|
||||
// teardown is unaffected by the simulated lock on *.mp4.
|
||||
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* #149: when the audio track is extracted successfully but the source video
|
||||
* cannot be deleted (Windows keeps files locked briefly — rmSync with
|
||||
* force:true still throws EBUSY/EPERM), the record must fall back to the
|
||||
* ORIGINAL container completely: both the path AND the recorded size.
|
||||
*
|
||||
* Committing the size before the delete succeeded would leave the record
|
||||
* claiming the small extracted size while still holding the whole video, so
|
||||
* totalBytes() under-counts and the upload directory grows past its quota.
|
||||
*
|
||||
* This lives in its own file because it partially mocks node:fs, which would
|
||||
* otherwise leak into every other test in local.test.ts.
|
||||
*/
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
rmSync: (path: string, opts?: object) => {
|
||||
// Simulate the lock on the source video only; every other delete
|
||||
// (the discarded .mka, temp dirs, the reject path) behaves normally.
|
||||
if (typeof path === "string" && path.endsWith(".mp4")) {
|
||||
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
|
||||
err.code = "EBUSY";
|
||||
throw err;
|
||||
}
|
||||
return actual.rmSync(path, opts as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { LocalMusicProvider } = await import("./local.js");
|
||||
|
||||
const ffmpeg: string | null = (() => {
|
||||
try {
|
||||
return createRequire(import.meta.url)("ffmpeg-static") as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
|
||||
afterEach(() => {
|
||||
// Recursive teardown without rmSync (mocked above for *.mp4).
|
||||
for (const f of readdirSync(dir)) {
|
||||
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
|
||||
}
|
||||
try { rmdirSync(dir); } catch { /* best effort */ }
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
|
||||
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
|
||||
const src = join(dir, "fixture.mp4");
|
||||
const r = spawnSync(ffmpeg!, [
|
||||
"-y", "-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
|
||||
"-f", "lavfi", "-i", "sine=f=440:d=3",
|
||||
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
|
||||
], { stdio: "ignore" });
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
const bytes = readFileSync(src);
|
||||
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
|
||||
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const song = await p.uploadAudio({
|
||||
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
|
||||
});
|
||||
|
||||
const resolved = await p.getSongUrl(song.id);
|
||||
expect(resolved).not.toBeNull();
|
||||
|
||||
// Fell back to the original container — the extract was discarded.
|
||||
expect(resolved!.url.endsWith(".mp4")).toBe(true);
|
||||
expect(existsSync(resolved!.url)).toBe(true);
|
||||
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
|
||||
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
|
||||
|
||||
const onDisk = statSync(resolved!.url).size;
|
||||
expect(onDisk).toBe(bytes.length);
|
||||
|
||||
// The RECORDED size drives the quota (totalBytes()), so it must describe
|
||||
// the file actually retained. It is not exposed through search()/toSong,
|
||||
// but it is persisted to index.json — read it back from there.
|
||||
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
|
||||
id: string; size: number; filePath: string;
|
||||
}>).find((r) => r.id === song.id);
|
||||
expect(record).toBeDefined();
|
||||
expect(record!.filePath.endsWith(".mp4")).toBe(true);
|
||||
// Before the fix this was the (much smaller) .mka size while the whole
|
||||
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
|
||||
expect(record!.size).toBe(bytes.length);
|
||||
|
||||
// Sanity: the extract really is much smaller, so a wrong commit order
|
||||
// would have been clearly observable rather than a rounding error.
|
||||
expect(onDisk).toBeGreaterThan(50_000);
|
||||
}, 60000);
|
||||
});
|
||||
+257
-2
@@ -1,8 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { buildFfmpegArgs } from "../audio/player.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { LocalMusicProvider } from "./local.js";
|
||||
import { LocalMusicProvider, parseMediaProbe } from "./local.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
@@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => {
|
||||
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// #149: video containers are accepted; only their audio track is kept.
|
||||
it("still rejects a non-media extension after video was allowed", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
for (const name of ["evil.exe", "evil.html", "evil.mp4.txt", "notes.pdf"]) {
|
||||
await expect(
|
||||
p.uploadAudio({ buffer: Buffer.from("x"), originalName: name, mimeType: "video/mp4" }),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts every supported video extension at the extension gate", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
// Junk content: ffmpeg cannot open it, so it is "unrecognised" rather than
|
||||
// "no audio track" and must be accepted exactly like a truncated .mp3
|
||||
// always has been. The extension allowlist is what is under test here.
|
||||
// .m4v is excluded on purpose — see the next test.
|
||||
for (const ext of [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".ts", ".m2ts", ".ogv"]) {
|
||||
const song = await p.uploadAudio({
|
||||
buffer: Buffer.from("not really a video"),
|
||||
originalName: `clip${ext}`,
|
||||
mimeType: "video/mp4",
|
||||
});
|
||||
expect(song.platform).toBe("local");
|
||||
expect(song.name).toBe("clip");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a .m4v raw video elementary stream, which by definition has no audio", async () => {
|
||||
// .m4v is not a container — ffmpeg's rawvideo demuxer opens arbitrary
|
||||
// bytes as an MPEG-4 video elementary stream, so it IS recognised and
|
||||
// genuinely carries no audio track. Refusing it is the correct outcome,
|
||||
// and it is the one case that distinguishes `recognized` from `probed`.
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({
|
||||
buffer: Buffer.from("not really a video"),
|
||||
originalName: "clip.m4v",
|
||||
mimeType: "video/x-m4v",
|
||||
}),
|
||||
).rejects.toThrow(/音轨/);
|
||||
});
|
||||
|
||||
it("the error message names both audio and video formats", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({ buffer: Buffer.from("x"), originalName: "a.exe" }),
|
||||
).rejects.toThrow(/视频/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMediaProbe (#149)", () => {
|
||||
const mp4Banner = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':
|
||||
Duration: 00:03:27.15, start: 0.000000, bitrate: 1105 kb/s
|
||||
Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1280x720, 30 fps
|
||||
Stream #0:1[0x2](und): Audio: aac (LC), 48000 Hz, stereo, fltp, 192 kb/s`;
|
||||
|
||||
it("reads duration and detects the audio stream in a video container", () => {
|
||||
const r = parseMediaProbe(mp4Banner);
|
||||
expect(r.durationSeconds).toBe(3 * 60 + 27);
|
||||
expect(r.hasAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("reports hasAudio false for a video with only a video stream", () => {
|
||||
const silent = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'silent.mp4':
|
||||
Duration: 00:00:02.00, start: 0.000000, bitrate: 29 kb/s
|
||||
Stream #0:0[0x1](und): Video: h264 (High 4:4:4 Predictive), yuv444p, 160x120, 10 fps`;
|
||||
const r = parseMediaProbe(silent);
|
||||
expect(r.durationSeconds).toBe(2);
|
||||
expect(r.hasAudio).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a plain audio file", () => {
|
||||
const r = parseMediaProbe(`Input #0, mp3, from 'a.mp3':
|
||||
Duration: 00:00:30.02, start: 0.000000, bitrate: 128 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s`);
|
||||
expect(r.durationSeconds).toBe(30);
|
||||
expect(r.hasAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mistake an attached cover image for an audio stream", () => {
|
||||
const r = parseMediaProbe(`Input #0, mp3, from 'cover.mp3':
|
||||
Duration: 00:00:10.00, start: 0.000000, bitrate: 130 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
|
||||
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 100x100 [attached pic]`);
|
||||
expect(r.hasAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("returns zeros on unparseable output rather than throwing", () => {
|
||||
const r = parseMediaProbe("ffmpeg: command exploded");
|
||||
expect(r.durationSeconds).toBe(0);
|
||||
expect(r.hasAudio).toBe(false);
|
||||
expect(r.recognized).toBe(false);
|
||||
});
|
||||
|
||||
// The distinction that decides whether an upload is refused: ffmpeg opened
|
||||
// the file and found no audio (refuse) vs ffmpeg could not open it at all
|
||||
// (accept, as it always has for truncated audio).
|
||||
it("marks a readable container recognized and unreadable bytes not", () => {
|
||||
expect(parseMediaProbe(mp4Banner).recognized).toBe(true);
|
||||
expect(parseMediaProbe(`[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found
|
||||
[in#0 @ 0x2] Error opening input: Invalid data found when processing input
|
||||
Error opening input file junk.mp4.`).recognized).toBe(false);
|
||||
});
|
||||
|
||||
it("rounds fractional durations", () => {
|
||||
expect(parseMediaProbe("Duration: 00:00:03.60,").durationSeconds).toBe(4);
|
||||
expect(parseMediaProbe("Duration: 01:02:03.10,").durationSeconds).toBe(3723);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider quota", () => {
|
||||
@@ -233,3 +345,146 @@ describe("LocalMusicProvider filename handling", () => {
|
||||
expect(await p.getSongUrl(song.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #149 end-to-end: build real containers with the bundled ffmpeg and push
|
||||
// them through the actual upload path. Skipped automatically if the binary is
|
||||
// unavailable, so the suite still runs on a machine without it.
|
||||
describe("LocalMusicProvider video upload, end to end (#149)", () => {
|
||||
const ffmpeg: string | null = (() => {
|
||||
try {
|
||||
return createRequire(import.meta.url)("ffmpeg-static") as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
/** Render a real container into the temp dir and return its bytes. */
|
||||
function render(name: string, args: string[]): Buffer {
|
||||
const out = join(dir, name);
|
||||
const r = spawnSync(ffmpeg!, ["-y", "-hide_banner", "-loglevel", "error", ...args, out], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
if (r.status !== 0) throw new Error(`fixture render failed: ${name}`);
|
||||
const buf = readFileSync(out);
|
||||
rmSync(out, { force: true }); // upload writes its own copy
|
||||
return buf;
|
||||
}
|
||||
|
||||
const withAudio = (dur: number, vcodec: string, acodec: string) => [
|
||||
"-f", "lavfi", "-i", `testsrc=s=160x120:r=10:d=${dur}`,
|
||||
"-f", "lavfi", "-i", `sine=f=440:d=${dur}`,
|
||||
"-c:v", vcodec, "-c:a", acodec, "-shortest",
|
||||
];
|
||||
|
||||
it.runIf(have)("accepts an mp4, reads its duration, and keeps only the audio", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const mp4 = render("src.mp4", withAudio(3, "libx264", "aac"));
|
||||
const song = await p.uploadAudio({
|
||||
buffer: mp4, originalName: "My Clip.mp4", mimeType: "video/mp4",
|
||||
});
|
||||
|
||||
expect(song.name).toBe("My Clip");
|
||||
expect(song.platform).toBe("local");
|
||||
expect(song.duration).toBe(3);
|
||||
|
||||
const resolved = await p.getSongUrl(song.id);
|
||||
expect(resolved).not.toBeNull();
|
||||
// The video container is gone; what remains is the extracted audio track.
|
||||
// AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming
|
||||
// edit list survives — see extractedAudioExt.
|
||||
expect(resolved!.url.endsWith(".m4a")).toBe(true);
|
||||
expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false);
|
||||
expect(existsSync(resolved!.url)).toBe(true);
|
||||
expect(statSync(resolved!.url).size).toBeGreaterThan(0);
|
||||
expect(statSync(resolved!.url).size).toBeLessThan(mp4.length);
|
||||
}, 60000);
|
||||
|
||||
it.runIf(have)("extracted audio is still decodable by the player's ffmpeg args", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const song = await p.uploadAudio({
|
||||
buffer: render("src2.mp4", withAudio(2, "libx264", "aac")),
|
||||
originalName: "clip.mp4",
|
||||
mimeType: "video/mp4",
|
||||
});
|
||||
const url = (await p.getSongUrl(song.id))!.url;
|
||||
|
||||
const decoded = spawnSync(
|
||||
ffmpeg!,
|
||||
[...buildFfmpegArgs(url, 0).slice(0, -1), "-"],
|
||||
{ maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
expect(decoded.status).toBe(0);
|
||||
// 2s of 48 kHz stereo s16le ≈ 384000 bytes; allow codec priming slack.
|
||||
expect(decoded.stdout.length).toBeGreaterThan(300000);
|
||||
}, 60000);
|
||||
|
||||
it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => {
|
||||
// The strongest statement of "lossless": decode the audio track straight
|
||||
// out of the source mp4, decode the stored extract, compare the PCM.
|
||||
// A Matroska remux would NOT pass this — it loses the MP4 edit list that
|
||||
// discards AAC encoder priming, so it decodes ~23 ms longer.
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac"));
|
||||
const sourceCopy = join(dir, "source-kept.mp4");
|
||||
writeFileSync(sourceCopy, bytes);
|
||||
|
||||
const song = await p.uploadAudio({
|
||||
buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4",
|
||||
});
|
||||
const url = (await p.getSongUrl(song.id))!.url;
|
||||
|
||||
const toPcm = (input: string, pre: string[] = []) => spawnSync(
|
||||
ffmpeg!,
|
||||
["-hide_banner", "-loglevel", "error", "-i", input, ...pre,
|
||||
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"],
|
||||
{ maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]);
|
||||
const fromExtract = toPcm(url);
|
||||
expect(fromVideo.status).toBe(0);
|
||||
expect(fromExtract.status).toBe(0);
|
||||
expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length);
|
||||
expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true);
|
||||
}, 90000);
|
||||
|
||||
it.runIf(have)("refuses a video that genuinely has no audio track", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const silent = render("silent.mp4", [
|
||||
"-f", "lavfi", "-i", "testsrc=s=160x120:r=10:d=2", "-an",
|
||||
]);
|
||||
await expect(
|
||||
p.uploadAudio({ buffer: silent, originalName: "silent.mp4", mimeType: "video/mp4" }),
|
||||
).rejects.toThrow(/音轨/);
|
||||
// The rejected upload must not leave its bytes behind.
|
||||
expect(readdirSync(dir).filter((f) => f.endsWith(".mp4"))).toEqual([]);
|
||||
}, 60000);
|
||||
|
||||
it.runIf(have)("extracts losslessly from avi/mkv/flv too, not just mp4", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const cases: Array<[string, string[]]> = [
|
||||
["a.avi", withAudio(2, "mpeg4", "libmp3lame")],
|
||||
["a.mkv", withAudio(2, "libx264", "libopus")],
|
||||
["a.flv", withAudio(2, "flv", "libmp3lame")],
|
||||
];
|
||||
for (const [name, args] of cases) {
|
||||
const song = await p.uploadAudio({
|
||||
buffer: render(`src-${name}`, args), originalName: name, mimeType: "video/x-msvideo",
|
||||
});
|
||||
const url = (await p.getSongUrl(song.id))!.url;
|
||||
expect(url.endsWith(".mka")).toBe(true);
|
||||
expect(statSync(url).size).toBeGreaterThan(0);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it.runIf(have)("a plain audio upload is untouched — no extraction, original extension kept", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const mp3 = render("src.mp3", ["-f", "lavfi", "-i", "sine=f=440:d=2", "-c:a", "libmp3lame"]);
|
||||
const song = await p.uploadAudio({ buffer: mp3, originalName: "tune.mp3", mimeType: "audio/mpeg" });
|
||||
const url = (await p.getSongUrl(song.id))!.url;
|
||||
expect(url.endsWith(".mp3")).toBe(true);
|
||||
expect(statSync(url).size).toBe(mp3.length); // byte-identical, not remuxed
|
||||
}, 60000);
|
||||
});
|
||||
+223
-24
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
@@ -34,6 +34,56 @@ const AUDIO_EXTENSIONS = new Set([
|
||||
".ape",
|
||||
]);
|
||||
|
||||
/** Video containers accepted for upload (#149). Only the audio track is ever
|
||||
* used — the bot has no video output. Playback would work straight from the
|
||||
* container (ffmpeg selects the audio stream), but we extract the audio on
|
||||
* upload so a 200 MB clip does not sit on disk for a 3 MB song; see
|
||||
* extractAudioTrack. `.webm` is deliberately absent: it is already in
|
||||
* AUDIO_EXTENSIONS and both audio-only and video .webm are handled there. */
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".flv",
|
||||
".wmv",
|
||||
".m4v",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".3gp",
|
||||
".ts",
|
||||
".m2ts",
|
||||
".ogv",
|
||||
]);
|
||||
|
||||
/** Fallback container for an extracted audio track. Matroska takes
|
||||
* essentially any audio codec, so `-c:a copy` works without knowing what the
|
||||
* source used — no re-encode, no codec/extension table. */
|
||||
const EXTRACTED_AUDIO_EXT = ".mka";
|
||||
|
||||
/**
|
||||
* Container to remux an extracted track into, chosen by its codec.
|
||||
*
|
||||
* AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder
|
||||
* priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list,
|
||||
* and that edit list does NOT survive into Matroska — so an aac→.mka remux
|
||||
* decodes ~23 ms longer than the source, with the priming samples audible at
|
||||
* the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the
|
||||
* track is then fractionally out of step with its own reported duration for
|
||||
* no reason. Copying aac into .m4a keeps the edit list and decodes
|
||||
* byte-for-byte identical to the audio inside the original video.
|
||||
*
|
||||
* AAC is worth special-casing because it is what mp4 / mov / m4v — the
|
||||
* formats people actually upload — almost always carry.
|
||||
*/
|
||||
function extractedAudioExt(codec: string | null): string {
|
||||
return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT;
|
||||
}
|
||||
|
||||
function isSupportedUploadExt(ext: string): boolean {
|
||||
return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES = 200;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
|
||||
|
||||
@@ -71,37 +121,128 @@ function titleFromFileName(name: string): string {
|
||||
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
|
||||
}
|
||||
|
||||
async function probeDurationSeconds(filePath: string): Promise<number> {
|
||||
export interface MediaProbe {
|
||||
/** Rounded seconds, 0 when the probe failed or the container has no duration. */
|
||||
durationSeconds: number;
|
||||
/** True when ffmpeg reported at least one audio stream. Only meaningful
|
||||
* together with `recognized` — see the comment there. */
|
||||
hasAudio: boolean;
|
||||
/** Lowercased codec name of the first audio stream ("aac", "mp3", "opus",
|
||||
* …), or null when there is none. Picks the remux container. */
|
||||
audioCodec: string | null;
|
||||
/**
|
||||
* True when ffmpeg actually opened the container and printed its
|
||||
* `Input #0, <format>, from '...'` header.
|
||||
*
|
||||
* This is what separates "ffmpeg looked inside and there is genuinely no
|
||||
* audio track" from "ffmpeg could not make sense of these bytes at all".
|
||||
* Both produce hasAudio === false, but only the first is a file we should
|
||||
* refuse. Unreadable bytes have always been accepted here (a truncated mp3
|
||||
* uploads fine and simply reports duration 0), and that stays true.
|
||||
*/
|
||||
recognized: boolean;
|
||||
/** False when ffmpeg could not be run or timed out, so nothing else in this
|
||||
* object is meaningful and the caller must not reject the file on it. */
|
||||
probed: boolean;
|
||||
}
|
||||
|
||||
/** Parse `Duration: HH:MM:SS.ss`, the `Input #0,` header and
|
||||
* `Stream #0:N...: Audio:` out of the banner ffmpeg prints on stderr when
|
||||
* asked to open a file with no output. */
|
||||
export function parseMediaProbe(stderr: string): Omit<MediaProbe, "probed"> {
|
||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
let durationSeconds = 0;
|
||||
if (match) {
|
||||
const total = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
||||
durationSeconds = Number.isFinite(total) ? Math.round(total) : 0;
|
||||
}
|
||||
// e.g. " Stream #0:1[0x2](und): Audio: aac (LC) ..." — the stream index and
|
||||
// the bracketed id/language vary, so match on the "Audio:" tag itself. An
|
||||
// embedded cover image is a separate "Video: mjpeg ... [attached pic]" line
|
||||
// and never matches this.
|
||||
const audioMatch = stderr.match(/Stream #\d+:\d+[^\n]*:\s*Audio:\s*([A-Za-z0-9_]+)/);
|
||||
const hasAudio = audioMatch !== null;
|
||||
const audioCodec = audioMatch ? audioMatch[1].toLowerCase() : null;
|
||||
// "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':" — absent entirely
|
||||
// when ffmpeg bails with "Error opening input: Invalid data found ...".
|
||||
const recognized = /^Input #\d+,/m.test(stderr);
|
||||
return { durationSeconds, hasAudio, audioCodec, recognized };
|
||||
}
|
||||
|
||||
async function probeMedia(filePath: string): Promise<MediaProbe> {
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const done = (probe: MediaProbe) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(probe);
|
||||
};
|
||||
// Video containers are much larger than the audio files this used to see,
|
||||
// and the probe only reads headers — but a network/USB path can still be
|
||||
// slow, so allow more than the old 5s before giving up.
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill("SIGKILL");
|
||||
resolve(0);
|
||||
}, 5000);
|
||||
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
|
||||
}, 20000);
|
||||
ffmpeg.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
ffmpeg.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(0);
|
||||
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
|
||||
});
|
||||
ffmpeg.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (!match) {
|
||||
resolve(0);
|
||||
return;
|
||||
done({ ...parseMediaProbe(stderr), probed: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const total = hours * 3600 + minutes * 60 + seconds;
|
||||
resolve(Number.isFinite(total) ? Math.round(total) : 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Remux the first audio stream of `source` into `target` (#149).
|
||||
*
|
||||
* `-c:a copy` — the audio is moved bit-for-bit into a Matroska audio
|
||||
* container, so this is fast, lossless, and codec-agnostic. Nothing is
|
||||
* re-encoded, so a 200 MB .mp4 becomes a few MB .mka with the original audio
|
||||
* intact. Video, subtitle and data streams are dropped.
|
||||
*
|
||||
* Returns true only if ffmpeg exited 0 AND produced a non-empty file, so a
|
||||
* partial/zero-byte result can never be mistaken for a successful extraction.
|
||||
* Callers fall back to keeping the original container, which plays fine.
|
||||
*/
|
||||
async function extractAudioTrack(source: string, target: string): Promise<boolean> {
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
const ffmpeg = spawn(
|
||||
ffmpegPath || "ffmpeg",
|
||||
["-hide_banner", "-loglevel", "error", "-y", "-i", source,
|
||||
"-vn", "-sn", "-dn", "-map", "0:a:0", "-c:a", "copy", target],
|
||||
{ stdio: ["ignore", "ignore", "ignore"] },
|
||||
);
|
||||
let settled = false;
|
||||
const done = (v: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(v);
|
||||
};
|
||||
// Remuxing is I/O bound, but a multi-GB input on a slow disk still takes
|
||||
// a while. Cap it so a pathological file cannot wedge the upload request.
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill("SIGKILL");
|
||||
done(false);
|
||||
}, 120000);
|
||||
ffmpeg.on("error", () => { clearTimeout(timeout); done(false); });
|
||||
ffmpeg.on("close", (code) => { clearTimeout(timeout); done(code === 0); });
|
||||
});
|
||||
if (!ok) return false;
|
||||
try {
|
||||
return statSync(target).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalMusicProvider implements MusicProvider {
|
||||
@@ -171,33 +312,91 @@ export class LocalMusicProvider implements MusicProvider {
|
||||
const ext = path.extname(originalName).toLowerCase();
|
||||
// Validate by the (sanitised) file extension only — never trust the
|
||||
// client-supplied Content-Type. This also guarantees the STORED extension
|
||||
// is one of the known audio types, so a spoofed header cannot persist an
|
||||
// arbitrary-extension blob on disk.
|
||||
if (!AUDIO_EXTENSIONS.has(ext)) {
|
||||
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
|
||||
// is one of the known audio/video types, so a spoofed header cannot
|
||||
// persist an arbitrary-extension blob on disk.
|
||||
if (!isSupportedUploadExt(ext)) {
|
||||
throw new Error(
|
||||
"只支持常见音频文件(mp3、flac、wav、m4a、ogg、opus、aac、webm 等)" +
|
||||
"和视频文件(mp4、mov、avi、mkv、flv、wmv 等,仅取其中的音轨播放)",
|
||||
);
|
||||
}
|
||||
if (!input.buffer || input.buffer.length === 0) {
|
||||
throw new Error("上传文件为空");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const storedName = `${id}${ext}`;
|
||||
const filePath = path.join(this.uploadDir, storedName);
|
||||
const isVideo = VIDEO_EXTENSIONS.has(ext);
|
||||
let filePath = path.join(this.uploadDir, `${id}${ext}`);
|
||||
writeFileSync(filePath, input.buffer);
|
||||
|
||||
const duration = await probeDurationSeconds(filePath);
|
||||
let probe: MediaProbe;
|
||||
try {
|
||||
probe = await probeMedia(filePath);
|
||||
} catch {
|
||||
probe = { durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false };
|
||||
}
|
||||
|
||||
// Reject a video with no audio track up front (#149). Left to playback it
|
||||
// would produce a silent, zero-byte stream that just looks like a broken
|
||||
// song. Require `recognized` as well as `probed`: bytes ffmpeg cannot open
|
||||
// at all report hasAudio false for a different reason, and those have
|
||||
// always been accepted (a truncated upload lands with duration 0) — this
|
||||
// change must not start rejecting them.
|
||||
if (isVideo && probe.probed && probe.recognized && !probe.hasAudio) {
|
||||
rmSync(filePath, { force: true });
|
||||
throw new Error("这个视频里没有音轨,无法播放");
|
||||
}
|
||||
|
||||
let size = input.buffer.length;
|
||||
if (isVideo) {
|
||||
// Keep only the audio. The video bytes are dead weight against the
|
||||
// upload-directory quota and would never be used.
|
||||
// Preferred container first; if that remux fails (a codec the container
|
||||
// will not take), retry into Matroska, which takes almost anything.
|
||||
const preferredExt = extractedAudioExt(probe.audioCodec);
|
||||
let extracted = path.join(this.uploadDir, `${id}${preferredExt}`);
|
||||
let ok = await extractAudioTrack(filePath, extracted);
|
||||
if (!ok && preferredExt !== EXTRACTED_AUDIO_EXT) {
|
||||
rmSync(extracted, { force: true });
|
||||
extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`);
|
||||
ok = await extractAudioTrack(filePath, extracted);
|
||||
}
|
||||
if (ok) {
|
||||
try {
|
||||
// Commit filePath and size TOGETHER, and only after the source is
|
||||
// actually gone. rmSync(force) still throws EBUSY/EPERM on Windows,
|
||||
// and assigning size first would leave the record claiming the
|
||||
// small extracted size while still pointing at the whole video —
|
||||
// which makes totalBytes() under-count and lets the upload
|
||||
// directory grow past its quota.
|
||||
const extractedSize = statSync(extracted).size;
|
||||
rmSync(filePath, { force: true });
|
||||
filePath = extracted;
|
||||
size = extractedSize;
|
||||
} catch {
|
||||
// Could not stat/remove (Windows lock) — keep playing the original
|
||||
// container and drop the half-finished extract.
|
||||
rmSync(extracted, { force: true });
|
||||
}
|
||||
} else {
|
||||
// Extraction failed (exotic codec Matroska won't take, timeout, …).
|
||||
// The original container still plays: ffmpeg picks its audio stream.
|
||||
rmSync(extracted, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const song: LocalSongRecord = {
|
||||
id,
|
||||
name: titleFromFileName(originalName),
|
||||
artist: "本地上传",
|
||||
album: "本地音乐",
|
||||
duration,
|
||||
duration: probe.durationSeconds,
|
||||
coverUrl: "",
|
||||
platform: "local",
|
||||
filePath,
|
||||
originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: input.buffer.length,
|
||||
size,
|
||||
mimeType: input.mimeType || "application/octet-stream",
|
||||
};
|
||||
|
||||
|
||||
+176
-1
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+323
-1
@@ -14,7 +14,7 @@ import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
import { createMusicRouter, createLocalUploadBody } from "./music.js";
|
||||
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
|
||||
@@ -295,3 +295,325 @@ describe("music router POST /quality — persistence (#125)", () => {
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
});
|
||||
|
||||
// #149: video containers must survive the transport layer. Before this the
|
||||
// express.raw type filter only matched audio/*, video/webm and
|
||||
// application/octet-stream, so a browser-sent video/mp4 body was never parsed
|
||||
// and the handler answered 400 "raw audio body is required".
|
||||
describe("music router POST /local/upload — content types and size cap (#149)", () => {
|
||||
let app: express.Express;
|
||||
let botDb: BotDatabase;
|
||||
let cookie: string;
|
||||
let uploadAudio: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const admin = await users.createUser("admin", "pw-admin", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||
|
||||
uploadAudio = vi.fn(async (input: { originalName: string }) => ({
|
||||
id: "local-1", name: input.originalName, artist: "本地上传", album: "本地音乐",
|
||||
duration: 1, coverUrl: "", platform: "local",
|
||||
}));
|
||||
const local = { platform: "local", search: vi.fn().mockResolvedValue(empty), uploadAudio } as unknown as MusicProvider;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app.use("/api/music", createMusicRouter(
|
||||
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }), local, getDefaultConfig(),
|
||||
));
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
const post = (contentType: string, body: Buffer, name = "clip.mp4") =>
|
||||
request(app)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Cookie", cookie)
|
||||
.set("Content-Type", contentType)
|
||||
.set("X-Filename", encodeURIComponent(name))
|
||||
.send(body);
|
||||
|
||||
it("accepts the video MIME types browsers actually send", async () => {
|
||||
// These are what Chrome/Firefox put on a File for .mp4/.mov/.avi/.mkv.
|
||||
for (const ct of ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/webm"]) {
|
||||
uploadAudio.mockClear();
|
||||
const res = await post(ct, Buffer.from("fake video bytes"));
|
||||
expect(res.status, `content-type ${ct}`).toBe(200);
|
||||
expect(uploadAudio).toHaveBeenCalledOnce();
|
||||
expect(res.body.song.platform).toBe("local");
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts audio and octet-stream bodies", async () => {
|
||||
for (const ct of ["audio/mpeg", "audio/flac", "application/octet-stream"]) {
|
||||
uploadAudio.mockClear();
|
||||
const res = await post(ct, Buffer.from("fake audio"), "tune.mp3");
|
||||
expect(res.status, `content-type ${ct}`).toBe(200);
|
||||
expect(uploadAudio).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the decoded filename and the content type through to the provider", async () => {
|
||||
await post("video/mp4", Buffer.from("bytes"), "我的 视频.mp4");
|
||||
expect(uploadAudio).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ originalName: "我的 视频.mp4", mimeType: "video/mp4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a provider rejection as a 400 with its message", async () => {
|
||||
uploadAudio.mockRejectedValueOnce(new Error("这个视频里没有音轨,无法播放"));
|
||||
const res = await post("video/mp4", Buffer.from("bytes"));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("这个视频里没有音轨,无法播放");
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.from("bytes"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an oversize body as JSON, not an HTML stack trace", async () => {
|
||||
// Same middleware the route mounts, built with a small limit so the test
|
||||
// does not have to allocate half a gigabyte to reach the cap.
|
||||
const tiny = express();
|
||||
const reached = vi.fn();
|
||||
tiny.post("/u", createLocalUploadBody("1kb"), (_req, res) => { reached(); res.json({ ok: true }); });
|
||||
|
||||
const res = await request(tiny)
|
||||
.post("/u")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.alloc(4096, 1));
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.headers["content-type"]).toMatch(/application\/json/);
|
||||
expect(res.body.error).toContain("文件太大");
|
||||
// The HTML default handler leaked absolute server paths and a stack.
|
||||
expect(res.text).not.toMatch(/node_modules|<\/pre>|at read/);
|
||||
expect(reached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a body under the cap through the same middleware", async () => {
|
||||
const tiny = express();
|
||||
tiny.post("/u", createLocalUploadBody("1kb"), (req, res) => {
|
||||
res.json({ bytes: (req.body as Buffer).length });
|
||||
});
|
||||
const res = await request(tiny)
|
||||
.post("/u")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.alloc(512, 1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bytes).toBe(512);
|
||||
});
|
||||
|
||||
it("rejects local uploads when the feature is switched off", async () => {
|
||||
const off = getDefaultConfig();
|
||||
off.localAudioEnabled = false;
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const a2 = await users.createUser("admin2", "pw-admin2", "admin");
|
||||
const c2 = `${SESSION_COOKIE_NAME}=${sessions.createSession(a2.id).token}`;
|
||||
const app2 = express();
|
||||
app2.use(cookieParser());
|
||||
app2.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app2.use("/api/music", createMusicRouter(
|
||||
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }),
|
||||
{ platform: "local", search: vi.fn(), uploadAudio } as unknown as MusicProvider, off,
|
||||
));
|
||||
const res = await request(app2)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Cookie", c2)
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.from("bytes"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(uploadAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("music router GET /bilibili/parts", () => {
|
||||
it("returns 400 when bvid is missing", async () => {
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
fakeProvider("bilibili"),
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("bvid is required");
|
||||
});
|
||||
|
||||
it("returns parts from bilibili provider when available", async () => {
|
||||
const mockBilibili = {
|
||||
platform: "bilibili" as const,
|
||||
search: vi.fn(),
|
||||
getVideoParts: vi.fn().mockResolvedValue({
|
||||
bvid: "BV1test",
|
||||
title: "多P视频测试",
|
||||
parts: [
|
||||
{ part: 1, cid: 101, title: "P1", duration: 100 },
|
||||
{ part: 2, cid: 102, title: "P2", duration: 200 },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
mockBilibili as unknown as MusicProvider,
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bvid).toBe("BV1test");
|
||||
expect(res.body.parts).toHaveLength(2);
|
||||
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
|
||||
});
|
||||
|
||||
it("returns 404 when getVideoParts returns null", async () => {
|
||||
const mockBilibili = {
|
||||
platform: "bilibili" as const,
|
||||
search: vi.fn(),
|
||||
getVideoParts: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const router = createMusicRouter(
|
||||
fakeProvider("netease"),
|
||||
fakeProvider("qq"),
|
||||
mockBilibili as unknown as MusicProvider,
|
||||
pino({ level: "silent" })
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
|
||||
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+115
-7
@@ -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";
|
||||
@@ -7,6 +7,56 @@ import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
|
||||
/**
|
||||
* Body cap for a local upload. express.raw buffers the whole body in memory,
|
||||
* so this is also the peak RAM one upload can cost — raised from 200mb for
|
||||
* video (#149), which is far bigger than audio for the same song, but kept
|
||||
* well short of "any video file at all" for that reason. Only the audio track
|
||||
* survives to disk.
|
||||
*/
|
||||
export const LOCAL_UPLOAD_LIMIT = "500mb";
|
||||
|
||||
/**
|
||||
* Body parser for the local-upload route.
|
||||
*
|
||||
* `type` includes "video/*" (#149): the browser sends the File's own MIME
|
||||
* type, so an .mp4 arrives as video/mp4 and used to be rejected by this
|
||||
* filter before ever reaching the provider. Only the audio track is kept —
|
||||
* uploadAudio remuxes it out on the way in.
|
||||
*
|
||||
* express.raw hands an oversize body to the default error handler, which
|
||||
* answers with an HTML page carrying a stack trace and absolute server paths
|
||||
* (unless NODE_ENV=production, which this project never sets). Video makes
|
||||
* hitting the cap far more likely than audio did, so that one case is
|
||||
* translated into the same JSON shape the rest of this route returns. Any
|
||||
* other body-parser error is passed on untouched.
|
||||
*
|
||||
* Exported as a factory so tests can drive the identical path with a small
|
||||
* limit instead of allocating half a gigabyte.
|
||||
*/
|
||||
export function createLocalUploadBody(limit: string): express.RequestHandler {
|
||||
const raw = express.raw({
|
||||
type: ["audio/*", "video/*", "application/octet-stream"],
|
||||
limit,
|
||||
});
|
||||
return (req, res, next) => {
|
||||
raw(req, res, (err?: unknown) => {
|
||||
if (!err) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const e = err as { type?: string; status?: number };
|
||||
if (e?.type === "entity.too.large" || e?.status === 413) {
|
||||
res.status(413).json({ error: `文件太大,单个文件上限 ${limit}` });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const localUploadBody = createLocalUploadBody(LOCAL_UPLOAD_LIMIT);
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
qqProvider: MusicProvider,
|
||||
@@ -65,10 +115,7 @@ export function createMusicRouter(
|
||||
}
|
||||
next();
|
||||
},
|
||||
express.raw({
|
||||
type: ["audio/*", "video/webm", "application/octet-stream"],
|
||||
limit: "200mb",
|
||||
}),
|
||||
localUploadBody,
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (!localProvider) {
|
||||
@@ -151,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),
|
||||
@@ -179,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 });
|
||||
@@ -250,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);
|
||||
@@ -335,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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import pino from "pino";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createApiKeyStore, type ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
|
||||
@@ -13,7 +14,7 @@ import type { GuestPermissions, BotAccess } from "../../data/permissions.js";
|
||||
import { createSessionRouter } from "./session.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore, apiKeys?: ApiKeyStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
@@ -27,7 +28,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
audit,
|
||||
pino({ level: "silent" }),
|
||||
permissions,
|
||||
() => getDefaultConfig().guestMode
|
||||
() => getDefaultConfig().guestMode,
|
||||
apiKeys
|
||||
)
|
||||
);
|
||||
return app;
|
||||
@@ -167,6 +169,74 @@ describe("session router", () => {
|
||||
expect(meB.status).toBe(401);
|
||||
|
||||
expect(u.id).toBe(meA.body.id);
|
||||
// 20s, not the 5s default: this case runs SIX bcryptjs rounds (one hash to
|
||||
// create the user, four verifies, one hash for the new password), and
|
||||
// bcryptjs is pure JS. It takes ~4.5s on an idle machine — close enough to
|
||||
// the default that it tipped over whenever the full suite saturated the
|
||||
// CPU, which made it look like a real intermittent failure. The work is
|
||||
// genuinely slow, not hung, so the timeout is what was wrong.
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("session router — API key revocation", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let apiKeys: ApiKeyStore;
|
||||
let app: express.Express;
|
||||
let userId: string;
|
||||
let currentCookie: string;
|
||||
let rawKey: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
apiKeys = createApiKeyStore(botDb.db);
|
||||
const member = await users.createUser("alice", "old-password", "member");
|
||||
userId = member.id;
|
||||
currentCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||
rawKey = apiKeys.create(userId, "integration")!.rawKey;
|
||||
app = makeApp(botDb, users, sessions, apiKeys);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("successful password change revokes all owned keys and preserves only the active browser session", async () => {
|
||||
const secondKey = apiKeys.create(userId, "another-integration")!.rawKey;
|
||||
const otherSession = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||
const otherUser = await users.createUser("bob", "other-password", "member");
|
||||
const otherUserKey = apiKeys.create(otherUser.id, "other-user-integration")!.rawKey;
|
||||
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.set("Cookie", currentCookie)
|
||||
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||
expect(changed.status).toBe(204);
|
||||
expect(apiKeys.validateAndTouch(rawKey)).toBeNull();
|
||||
expect(apiKeys.validateAndTouch(secondKey)).toBeNull();
|
||||
expect(apiKeys.listForUser(userId)).toEqual([]);
|
||||
expect(apiKeys.validateAndTouch(otherUserKey)?.userId).toBe(otherUser.id);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", otherSession)).status).toBe(401);
|
||||
}, 20_000);
|
||||
|
||||
it.each([
|
||||
{ oldPassword: "wrong-password", newPassword: "new-password", status: 401 },
|
||||
{ oldPassword: "old-password", newPassword: "short", status: 400 },
|
||||
])("failed password change ($status) leaves API keys valid", async ({ oldPassword, newPassword, status }) => {
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.set("Cookie", currentCookie)
|
||||
.send({ oldPassword, newPassword });
|
||||
expect(changed.status).toBe(status);
|
||||
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("unauthenticated password change leaves API keys valid", async () => {
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||
expect(changed.status).toBe(401);
|
||||
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}=`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
@echo off
|
||||
title TSMusicBot
|
||||
:: ============================================================
|
||||
:: TSMusicBot - convenience launcher at the repo root.
|
||||
:: Everything real lives in scripts\start.bat; this file only makes sure we
|
||||
:: run from the project directory and then delegates, so both entry points
|
||||
:: behave identically (same node/dist checks, same native-module preflight).
|
||||
:: ============================================================
|
||||
|
||||
:: Check node
|
||||
where node >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Node.js not found. Run scripts\setup.bat first.
|
||||
cd /d "%~dp0" || (
|
||||
echo [FATAL] Cannot change to the project directory.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting TSMusicBot...
|
||||
echo WebUI: http://localhost:3000
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
node dist\index.js
|
||||
|
||||
:: Keep lines inside parenthesised blocks pure ASCII: cmd.exe mis-tracks its
|
||||
:: file offset when a block contains multi-byte UTF-8 and eats the "echo " prefix.
|
||||
if not exist "scripts\start.bat" (
|
||||
echo scripts\start.bat not found - is this the TSMusicBot project folder?
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call "scripts\start.bat"
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,92 @@
|
||||
const { Client, generateIdentity } = require('@honeybbq/teamspeak-client');
|
||||
|
||||
function escapeTS3(value) {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\//g, "\\/")
|
||||
.replace(/ /g, "\\s")
|
||||
.replace(/\|/g, "\\p")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
function replaceField(cmd, key, value) {
|
||||
const escaped = escapeTS3(value);
|
||||
const regex = new RegExp(key + "=\\S*");
|
||||
if (regex.test(cmd)) return cmd.replace(regex, key + "=" + escaped);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Version to test - passed via env
|
||||
const VERSIONS = [
|
||||
{
|
||||
name: "6.0.0-beta2",
|
||||
version: "6.0.0-beta2 [Build: 1737468425]",
|
||||
platform: "Windows",
|
||||
sign: "b5mySiqYAd4Lv5TZNflW+G5Gk8y7Woo9YnJfHRjmzhUyMdGfF1O7fSGJzmx2Hpe/PKaY2aDuKKD6lSxbLYlKCw==",
|
||||
},
|
||||
{
|
||||
name: "3.?.? wildcard",
|
||||
version: "3.?.? [Build: 5680278000]",
|
||||
platform: "Windows",
|
||||
sign: "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==",
|
||||
},
|
||||
{
|
||||
name: "5.0.0-beta77",
|
||||
version: "5.0.0-beta77 [Build: 1702382332]",
|
||||
platform: "Windows",
|
||||
sign: "Ee6DzP16MUXpdKWjiSY0NGb4thN22/Ks0hwNcaMrWoaadgkM6c5477X0IbGFWVjzTWfjFTEad5noYLUPDWSgCQ==",
|
||||
},
|
||||
{
|
||||
name: "3.6.2 (corrected sign)",
|
||||
version: "3.6.2 [Build: 1695203293]",
|
||||
platform: "Windows",
|
||||
sign: "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw==",
|
||||
},
|
||||
];
|
||||
|
||||
const idx = parseInt(process.env.VERSION_IDX || "0");
|
||||
const V = VERSIONS[idx];
|
||||
|
||||
const identity = generateIdentity(8);
|
||||
const client = new Client(identity, "localhost:9987", "MusicBot", {
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: (m) => console.log("[INFO]", m),
|
||||
warn: (m) => console.log("[WARN]", m),
|
||||
error: (m) => console.log("[ERROR]", m),
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Testing version: " + V.name + " -> " + V.version);
|
||||
client.connect().then(() => {
|
||||
const handler = client.handler;
|
||||
const origSendPacket = handler.sendPacket.bind(handler);
|
||||
handler.sendPacket = (pType, data, flags) => {
|
||||
if (pType === 2) {
|
||||
let str = Buffer.from(data).toString("utf-8");
|
||||
if (str.startsWith("clientinit ")) {
|
||||
str = replaceField(str, "client_version", V.version);
|
||||
str = replaceField(str, "client_platform", V.platform);
|
||||
str = replaceField(str, "client_version_sign", V.sign);
|
||||
console.log("[PATCHED] version=" + V.version);
|
||||
origSendPacket(pType, Buffer.from(str), flags);
|
||||
return;
|
||||
}
|
||||
}
|
||||
origSendPacket(pType, data, flags);
|
||||
};
|
||||
return client.waitConnected();
|
||||
}).then(() => {
|
||||
console.log("SUCCESS! Connected with clientId = " + client.clientID());
|
||||
client.disconnect();
|
||||
process.exit(0);
|
||||
}).catch((err) => {
|
||||
console.log("ERROR:", err && err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.log("TIMEOUT - version rejected");
|
||||
process.exit(2);
|
||||
}, 10000);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// TypeScript compiles test files into dist. Test the source once, even
|
||||
// when an older build is present, rather than collecting stale copies.
|
||||
exclude: [...configDefaults.exclude, "dist/**", "web/dist/**"],
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,22 @@
|
||||
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
|
||||
<meta name="referrer" content="same-origin">
|
||||
<title>TSMusicBot</title>
|
||||
<!-- Icons live in web/public/ so Vite copies them to the dist root, which is
|
||||
what Express serves (src/index.ts STATIC_DIR). Both an .ico and an .svg
|
||||
are declared: browsers that understand the vector one prefer it and stay
|
||||
sharp on hi-dpi tabs, the rest fall back to the .ico. The .ico also
|
||||
answers the implicit /favicon.ico request — without a real file there the
|
||||
SPA catch-all hands back index.html with a 200, so the tab silently keeps
|
||||
the blank-page icon (issue #142). -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
|
||||
<!-- iOS home-screen icon; iOS masks the corners itself, so this one is a
|
||||
full square. Android reads the manifest instead. -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- Matches --bg-primary of the dark theme, which is what the app starts in
|
||||
(stores/player.ts defaults to 'dark' and never follows the OS scheme). -->
|
||||
<meta name="theme-color" content="#222222">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="22" fill="#335eea"/>
|
||||
<g fill="#ffffff" transform="translate(48.46 51.1) scale(0.88) translate(-50 -50)">
|
||||
<path d="M43.5 24 L86.5 15 L86.5 26 L43.5 35 Z"/>
|
||||
<rect x="43.5" y="24" width="6" height="47"/>
|
||||
<rect x="80.5" y="15" width="6" height="47"/>
|
||||
<ellipse cx="32" cy="71" rx="15" ry="11.5" transform="rotate(-20 32 71)"/>
|
||||
<ellipse cx="69" cy="62" rx="15" ry="11.5" transform="rotate(-20 69 62)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "TSMusicBot",
|
||||
"short_name": "TSMusicBot",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#222222",
|
||||
"theme_color": "#222222",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+231
-5
@@ -7,11 +7,23 @@
|
||||
<Player />
|
||||
<Toast />
|
||||
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
||||
<BilibiliPartModal />
|
||||
|
||||
<!-- Mobile mini player -->
|
||||
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
|
||||
<div class="m-player-progress">
|
||||
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
|
||||
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
||||
<div
|
||||
ref="seekBarRef"
|
||||
class="m-player-progress"
|
||||
:class="{ 'no-seek': !canSeek, dragging: seeking }"
|
||||
@pointerdown="onSeekDown"
|
||||
@pointermove="onSeekMove"
|
||||
@pointerup="onSeekUp"
|
||||
@pointercancel="onSeekCancel"
|
||||
>
|
||||
<div class="m-player-progress-track">
|
||||
<div class="m-player-progress-fill" :style="{ width: seekBarPct + '%' }" />
|
||||
<div class="m-player-progress-thumb" :style="{ left: seekBarPct + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
|
||||
<div class="m-player-info">
|
||||
@@ -79,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from './stores/player.js';
|
||||
@@ -91,6 +103,7 @@ import Player from './components/Player.vue';
|
||||
import CoverArt from './components/CoverArt.vue';
|
||||
import Toast from './components/Toast.vue';
|
||||
import Queue from './components/Queue.vue';
|
||||
import BilibiliPartModal from './components/BilibiliPartModal.vue';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const session = useSession();
|
||||
@@ -132,15 +145,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
// While the finger owns the bar, the clock must keep its hands off it — see
|
||||
// seekBarPct below. Skipping the write (rather than letting it be overridden)
|
||||
// also avoids 60 pointless reactive re-renders per second mid-drag.
|
||||
if (!seeking.value) {
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
||||
// leave the mobile bar frozen between server pushes (#107).
|
||||
mobileProgressPct.value = duration > 0
|
||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||
: 0;
|
||||
}
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
}
|
||||
|
||||
// --- Mini-player seek (#143) -------------------------------------------------
|
||||
// The mobile progress bar used to be display-only. It now supports tap-to-seek
|
||||
// and drag-to-seek via Pointer Events (one code path for touch, pen and mouse —
|
||||
// no mouse/touch handler pairs) with setPointerCapture, so the drag survives the
|
||||
// finger sliding off the 12px strip.
|
||||
//
|
||||
// Decoupling, exactly the reasoning of composables/useDecoupledSlider.ts (#111):
|
||||
// updateMobileProgress() rewrites the rendered percentage every animation frame
|
||||
// from the *server* clock, which is still the pre-seek position while the user
|
||||
// drags. Binding the bar straight to it would snap the fill back under the
|
||||
// finger ~60 times a second. So the rendered value is a computed that switches
|
||||
// its source: the finger while `seeking`, the clock otherwise.
|
||||
const seekBarRef = ref<HTMLElement | null>(null);
|
||||
const seeking = ref(false);
|
||||
const seekPct = ref(0);
|
||||
let seekPointerId: number | null = null;
|
||||
// The song the gesture started on. currentSong can advance mid-drag (the track
|
||||
// ends), and the ratio the finger picked means nothing against a different
|
||||
// song's duration.
|
||||
let seekSongId: string | null = null;
|
||||
// Bumped per gesture so a slow seek POST can't clear the override belonging to a
|
||||
// newer drag that started while it was still in flight.
|
||||
let seekGeneration = 0;
|
||||
// Timestamp of the last seek gesture end, used to swallow the trailing click
|
||||
// (see onPlayerRowClick).
|
||||
let seekEndedAt = 0;
|
||||
|
||||
const seekBarPct = computed(() => (seeking.value ? seekPct.value : mobileProgressPct.value));
|
||||
|
||||
/** Pointer x → 0..1 along the strip, or null when the element isn't measurable. */
|
||||
function seekRatio(e: PointerEvent): number | null {
|
||||
const el = seekBarRef.value;
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0) return null;
|
||||
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
/** Duration guard: live streams report 0/undefined and ratio*0 would seek to 0,
|
||||
* while a missing duration would produce NaN — which the API rejects with 400. */
|
||||
function seekableDuration(): number {
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : 0;
|
||||
}
|
||||
|
||||
// Drives the `no-seek` class as well as the gesture guard, so a bar that cannot
|
||||
// be seeked also gives `touch-action` back to the page — otherwise the strip
|
||||
// would be a 12px band that neither seeks nor scrolls.
|
||||
const canSeek = computed(() => canTransport.value && seekableDuration() > 0);
|
||||
|
||||
function endSeekGesture() {
|
||||
const el = seekBarRef.value;
|
||||
if (el && seekPointerId !== null && el.hasPointerCapture?.(seekPointerId)) {
|
||||
el.releasePointerCapture(seekPointerId);
|
||||
}
|
||||
seekPointerId = null;
|
||||
seekEndedAt = Date.now();
|
||||
}
|
||||
|
||||
function onSeekDown(e: PointerEvent) {
|
||||
// Seeking is gated on transport, like the desktop player's `no-seek` state:
|
||||
// without it the bar stays purely visual and taps fall through to the row.
|
||||
if (!canSeek.value) return;
|
||||
// One gesture at a time: a second finger landing on the strip would otherwise
|
||||
// steal seekPointerId, leaving the first pointer captured forever and
|
||||
// committing whichever finger happened to lift first.
|
||||
if (seekPointerId !== null) return;
|
||||
const ratio = seekRatio(e);
|
||||
if (ratio === null) return;
|
||||
// Never let the row's router.push('/lyrics') fire while the user is seeking.
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // suppress text selection / compat mouse events during the drag
|
||||
seekPointerId = e.pointerId;
|
||||
seekSongId = currentSong.value?.id ?? null;
|
||||
seekGeneration += 1;
|
||||
seekBarRef.value?.setPointerCapture?.(e.pointerId);
|
||||
seeking.value = true;
|
||||
seekPct.value = ratio * 100;
|
||||
}
|
||||
|
||||
function onSeekMove(e: PointerEvent) {
|
||||
if (!seeking.value || e.pointerId !== seekPointerId) return;
|
||||
const ratio = seekRatio(e);
|
||||
if (ratio === null) return;
|
||||
e.stopPropagation();
|
||||
seekPct.value = ratio * 100;
|
||||
}
|
||||
|
||||
async function onSeekUp(e: PointerEvent) {
|
||||
if (!seeking.value || e.pointerId !== seekPointerId) return;
|
||||
e.stopPropagation();
|
||||
// A tap never moves, so pointerup is also the commit point for tap-to-seek.
|
||||
const ratio = seekRatio(e) ?? seekPct.value / 100;
|
||||
seekPct.value = ratio * 100;
|
||||
const duration = seekableDuration();
|
||||
const generation = seekGeneration;
|
||||
// If the track advanced while the finger was down, the ratio belongs to a
|
||||
// song that is no longer playing — drop the seek rather than applying it to
|
||||
// whatever started next.
|
||||
const sameSong = currentSong.value?.id === seekSongId;
|
||||
endSeekGesture(); // must run synchronously, before the awaited POST
|
||||
try {
|
||||
if (duration > 0 && sameSong) await playerStore.seek(ratio * duration);
|
||||
} catch {
|
||||
// Seek rejected (403/400/offline) — fall back to the server clock below.
|
||||
} finally {
|
||||
// Release the local override only once seek() has resolved. store.seek()
|
||||
// moves its timing anchor to the requested position in the same tick, so
|
||||
// liveElapsed() already reports the new spot and the bar simply carries on
|
||||
// from where the finger left it. Releasing at pointerup instead would show
|
||||
// the *old* position for one round-trip and then jump a second time.
|
||||
// (_syncAfterAction re-polls 500ms later, but that only nudges the bar by
|
||||
// the network delta — not worth freezing the clock for.)
|
||||
if (generation === seekGeneration) seeking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSeekCancel(e: PointerEvent) {
|
||||
if (e.pointerId !== seekPointerId) return;
|
||||
// Gesture stolen (system gesture, call, …): abandon without seeking and hand
|
||||
// the bar straight back to the clock.
|
||||
endSeekGesture();
|
||||
seeking.value = false;
|
||||
}
|
||||
|
||||
// The whole mini player lives inside `v-if="currentSong"`, so when playback
|
||||
// stops mid-drag the strip is destroyed and no pointerup/pointercancel can ever
|
||||
// reach it — element removal is not a pointercancel trigger. Without this the
|
||||
// `seeking` override would stay true and the progress bar would sit frozen for
|
||||
// the rest of the session. Bumping the generation also neuters the finally of
|
||||
// any seek still in flight.
|
||||
watch(currentSong, () => {
|
||||
if (!seeking.value) return;
|
||||
seekGeneration += 1;
|
||||
seekPointerId = null;
|
||||
seekSongId = null;
|
||||
seeking.value = false;
|
||||
});
|
||||
|
||||
function onPlayerRowClick() {
|
||||
// Both a tap and a drag on the strip emit a trailing `click`, which would
|
||||
// otherwise navigate to /lyrics the moment the user finishes seeking. A
|
||||
// `@click.stop` on the strip is not enough: after a drag the click's target is
|
||||
// the nearest common ancestor of the pointerdown/pointerup hit-tests, i.e.
|
||||
// `.m-player` itself once the finger has left the 12px strip. So the row
|
||||
// swallows any click arriving right after a seek gesture. A timestamp rather
|
||||
// than a flag, so a gesture that produces no click at all (preventDefault,
|
||||
// pointercancel) can't leave the row permanently unclickable — and so an inert
|
||||
// strip (no transport permission, unknown duration) still falls through here
|
||||
// and navigates, exactly as it did before.
|
||||
if (Date.now() - seekEndedAt < 400) return;
|
||||
router.push('/lyrics');
|
||||
}
|
||||
|
||||
function toggleMobileVolume() {
|
||||
mobileVolumeOpen.value = !mobileVolumeOpen.value;
|
||||
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
|
||||
@@ -242,15 +414,69 @@ onUnmounted(() => {
|
||||
top: 0;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
// The visible track stays 2px, but 2px is not a touch target (#143), so the
|
||||
// hit area is 12px and grows DOWNWARD into the mini player's own 8px top
|
||||
// padding. It must not reach the transport buttons: they are 32px tall and
|
||||
// centred in the 58px row's 42px content box, i.e. their top edge sits at
|
||||
// 8 + (42 - 32) / 2 = 13px. 12px clears them by 1px. Growing upward is not an
|
||||
// option — that is outside the player's rounded top edge.
|
||||
height: 12px;
|
||||
// Without this the browser claims the gesture for page scrolling partway
|
||||
// through the drag and the pointermove stream stops.
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
// No transport permission → purely decorative (mirrors Player.vue's .no-seek).
|
||||
// Handing touch-action back matters: an inert strip must not eat gestures.
|
||||
&.no-seek {
|
||||
touch-action: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.m-player-progress-track {
|
||||
position: relative;
|
||||
// Nudged down inside the 12px hit area so the drag thumb, which is centred on
|
||||
// the track, stays within the card instead of poking out above its top edge.
|
||||
// The 8px thumb's box is (3 + 1 - 4) = 0 to 8, i.e. exactly flush with the
|
||||
// card. (The container is absolutely positioned, so it forms a BFC and this
|
||||
// margin cannot collapse through it.)
|
||||
margin-top: 3px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.m-player-progress-fill {
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.m-player-progress-thumb {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-full);
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m-player-progress.dragging .m-player-progress-thumb {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.m-player-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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>
|
||||
@@ -26,6 +26,7 @@
|
||||
:key="`${song.id}-${i}`"
|
||||
class="queue-item"
|
||||
:class="{ active: store.currentSong?.id === song.id }"
|
||||
@click="onRowClick($event, i)"
|
||||
@dblclick="playAtIndex(i)"
|
||||
>
|
||||
<CoverArt :url="song.coverUrl" :size="32" :radius="4" />
|
||||
@@ -33,7 +34,7 @@
|
||||
<div class="queue-song-name">{{ song.name }}</div>
|
||||
<div class="queue-song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除">
|
||||
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click.stop="removeSong(i)" title="移除">
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,6 +73,21 @@ async function playAtIndex(index: number) {
|
||||
await store.fetchQueue();
|
||||
}
|
||||
|
||||
// Touch has no `dblclick`, so double-click-to-play was dead in the mobile queue
|
||||
// drawer (#143). Same per-event touch rule as SongCard.vue — see the full
|
||||
// rationale in the `isTouchClick` comment there. Permission gating stays in
|
||||
// playAtIndex(); the remove button uses @click.stop so it can't double-fire.
|
||||
function isTouchClick(e: MouseEvent): boolean {
|
||||
const pointerType = (e as PointerEvent).pointerType;
|
||||
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
|
||||
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
||||
}
|
||||
|
||||
function onRowClick(e: MouseEvent, index: number) {
|
||||
if (!isTouchClick(e)) return;
|
||||
void playAtIndex(index);
|
||||
}
|
||||
|
||||
async function removeSong(index: number) {
|
||||
if (!store.activeBotId) return;
|
||||
try {
|
||||
@@ -205,4 +221,15 @@ async function clearAndStop() {
|
||||
color: var(--text-tertiary);
|
||||
&:hover { color: var(--text-primary); }
|
||||
}
|
||||
|
||||
// Touch devices have no :hover, so the parent-hover-reveals-the-button pattern
|
||||
// leaves an *invisible but still tappable* remove button on the right edge of
|
||||
// every row. Now that a single tap on the row plays (#143), that invisible
|
||||
// target reads as "I tapped to play and it deleted the song" — show it, same as
|
||||
// .song-actions in SongCard.vue.
|
||||
@media (pointer: coarse) {
|
||||
.remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
|
||||
<div class="song-card" :class="{ active }" @click="onRowClick" @dblclick="showPlay && $emit('play')">
|
||||
<div class="song-index">{{ index }}</div>
|
||||
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
|
||||
<div class="song-info">
|
||||
@@ -51,12 +51,41 @@ const showPlay = computed(() => can('player.control') || guestCan('playNow'));
|
||||
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
|
||||
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
play: [];
|
||||
playNext: [];
|
||||
add: [];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Was this click made with a finger/stylus rather than a mouse? (#143)
|
||||
*
|
||||
* `dblclick` is mouse-only and never fires on touch, so double-tap-to-play was
|
||||
* simply dead on phones. A single *tap* plays instead, while a single mouse
|
||||
* click must keep doing nothing — otherwise desktop behaviour changes and the
|
||||
* surviving dblclick would fire play twice.
|
||||
*
|
||||
* The check is per-EVENT, not per-device: a `click` is a PointerEvent in modern
|
||||
* browsers, so pointerType describes how *this* click was made. A global
|
||||
* matchMedia('(pointer: coarse)') check reports only the *primary* pointer and
|
||||
* is therefore wrong on hybrid laptops (touchscreen + trackpad); it is used only
|
||||
* as a fallback for browsers that give us no pointerType.
|
||||
*/
|
||||
function isTouchClick(e: MouseEvent): boolean {
|
||||
const pointerType = (e as PointerEvent).pointerType;
|
||||
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
|
||||
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
||||
}
|
||||
|
||||
// Listening on `click` rather than `pointerup` on purpose: the browser already
|
||||
// suppresses the click that ended a scroll gesture, so a tap that was really the
|
||||
// start of a flick can't hijack playback for everyone in the channel.
|
||||
// (The action buttons stop propagation, so they never double-fire this.)
|
||||
function onRowClick(e: MouseEvent) {
|
||||
if (!showPlay.value || !isTouchClick(e)) return;
|
||||
emit('play');
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
+349
-13
@@ -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
|
||||
@@ -28,18 +54,18 @@
|
||||
>
|
||||
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
|
||||
<div class="upload-copy">
|
||||
<div class="upload-title">拖拽本地音频到这里上传</div>
|
||||
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
|
||||
<div class="upload-title">拖拽本地音频 / 视频到这里上传</div>
|
||||
<div class="upload-subtitle">音频支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等,视频支持 mp4、mov、avi、mkv、flv、wmv 等(只取其中的音轨播放);上传后可直接播放或加入队列</div>
|
||||
</div>
|
||||
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
|
||||
{{ uploading ? '上传中...' : '选择音频' }}
|
||||
{{ uploading ? '上传中...' : '选择文件' }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
|
||||
accept="audio/*,video/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape,.mp4,.mov,.avi,.mkv,.flv,.wmv,.m4v,.mpg,.mpeg,.3gp,.ts,.m2ts,.ogv"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
@@ -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,19 +481,55 @@ 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);
|
||||
}
|
||||
|
||||
function isAudioFile(file: File): boolean {
|
||||
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
|
||||
/** 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;
|
||||
const UPLOAD_MAX_BYTES = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
|
||||
// Video is accepted too (#149) — the server keeps only the audio track.
|
||||
// Keep the extension list in sync with AUDIO_EXTENSIONS / VIDEO_EXTENSIONS in
|
||||
// src/music/local.ts; the server re-validates, this just avoids a round-trip.
|
||||
function isMediaFile(file: File): boolean {
|
||||
return file.type.startsWith('audio/')
|
||||
|| file.type.startsWith('video/')
|
||||
|| /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape|mp4|mov|avi|mkv|flv|wmv|m4v|mpg|mpeg|3gp|ts|m2ts|ogv)$/i.test(file.name);
|
||||
}
|
||||
|
||||
async function uploadLocalFiles(fileList: File[]) {
|
||||
@@ -414,10 +538,22 @@ async function uploadLocalFiles(fileList: File[]) {
|
||||
uploadMessage.value = '本地音频播放已关闭';
|
||||
return;
|
||||
}
|
||||
const files = fileList.filter(isAudioFile);
|
||||
const candidates = fileList.filter(isMediaFile);
|
||||
if (candidates.length === 0) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '没有找到可上传的音频 / 视频文件';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject oversize files before spending minutes uploading them (#149).
|
||||
// The server enforces the same cap (LOCAL_UPLOAD_LIMIT in
|
||||
// src/web/api/music.ts) and answers 413 — this only saves the round-trip,
|
||||
// which matters now that a single video can be hundreds of megabytes.
|
||||
const files = candidates.filter((f) => f.size <= UPLOAD_MAX_BYTES);
|
||||
const oversize = candidates.filter((f) => f.size > UPLOAD_MAX_BYTES);
|
||||
if (files.length === 0) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '没有找到可上传的音频文件';
|
||||
uploadMessage.value = `文件太大,单个文件上限 ${UPLOAD_MAX_MB} MB:${oversize[0].name}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -426,8 +562,19 @@ async function uploadLocalFiles(fileList: File[]) {
|
||||
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
|
||||
|
||||
const uploaded: Song[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const file of files) {
|
||||
const failed: string[] = oversize.map((f) => `${f.name}: 超过 ${UPLOAD_MAX_MB} MB 上限`);
|
||||
for (const [i, file] of files.entries()) {
|
||||
// Videos are orders of magnitude bigger than the audio files this used to
|
||||
// handle (#149), so a silent "正在上传..." can sit there for minutes and
|
||||
// look hung. Report per-file percentage while the bytes are in flight, and
|
||||
// switch to a processing note once the server takes over (it still has to
|
||||
// probe the file and remux the audio track out).
|
||||
const label = files.length > 1 ? `(${i + 1}/${files.length})` : '';
|
||||
const setProgress = (text: string) => {
|
||||
uploadMessageType.value = 'info';
|
||||
uploadMessage.value = `${text}${label}:${file.name}`;
|
||||
};
|
||||
setProgress('正在上传');
|
||||
try {
|
||||
const res = await axios.post('/api/music/local/upload', file, {
|
||||
headers: {
|
||||
@@ -435,6 +582,11 @@ async function uploadLocalFiles(fileList: File[]) {
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
onUploadProgress: (e) => {
|
||||
if (!e.total) return;
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
setProgress(pct >= 100 ? '服务端处理中' : `正在上传 ${pct}%`);
|
||||
},
|
||||
});
|
||||
if (res.data?.song) uploaded.push(res.data.song as Song);
|
||||
} catch (err: any) {
|
||||
@@ -509,6 +661,7 @@ async function loadLocalAudioSetting() {
|
||||
|
||||
onMounted(async () => {
|
||||
loadLocalAudioSetting();
|
||||
historyEntries.value = loadHistory();
|
||||
if (query.value) doSearch();
|
||||
await store.fetchProviders();
|
||||
fixupSelectedSource();
|
||||
@@ -612,6 +765,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
@@ -620,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;
|
||||
|
||||
+157
-2
@@ -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 -->
|
||||
@@ -387,7 +388,7 @@
|
||||
<img :src="qqQr.dataUrl" class="qr-image" alt="QR Code" />
|
||||
<div class="qr-status" :class="qqQr.status">
|
||||
<template v-if="qqQr.status === 'waiting'">
|
||||
<Icon icon="mdi:cellphone" /> 请使用QQ音乐APP扫码
|
||||
<Icon icon="mdi:cellphone" /> 请使用手机QQ扫码
|
||||
</template>
|
||||
<template v-else-if="qqQr.status === 'scanned'">
|
||||
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user