mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 05:52:50 +08:00
Compare commits
29
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 | ||
|
|
6b82df4e50 | ||
|
|
faf6ac09ec |
No files matched your search
@@ -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站热门视频和个性化推荐(登录后更准确)
|
||||
@@ -385,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(默认网易云,自动续播) |
|
||||
@@ -461,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`。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
@@ -906,7 +953,70 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
|
||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||
|
||||
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
|
||||
### 最新版本 — v1.15.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。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
|
||||
|
||||
|
||||
+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.
|
||||
@@ -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() };
|
||||
}
|
||||
+268
-1
@@ -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");
|
||||
@@ -64,6 +80,14 @@ describe("buildFfmpegArgs", () => {
|
||||
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||
});
|
||||
|
||||
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
|
||||
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
|
||||
const ssIdx = args.indexOf("-ss");
|
||||
expect(args[ssIdx + 1]).toBe("3600");
|
||||
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
|
||||
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
|
||||
});
|
||||
|
||||
it("does not insert -ss when seekSeconds is 0", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||
expect(args).not.toContain("-ss");
|
||||
@@ -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 }
|
||||
|
||||
+64
-12
@@ -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,9 +116,15 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
"-reconnect_on_http_error", "4xx,5xx",
|
||||
);
|
||||
}
|
||||
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
|
||||
// to the byte offset. Output-side seek would download and decode everything
|
||||
// before the target first — minutes for a resume deep into a 3-hour video
|
||||
// (#161), long enough to trip the stall watchdog.
|
||||
const inputSideSeek = isBilibili;
|
||||
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
|
||||
args.push("-i", url);
|
||||
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
||||
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
|
||||
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
|
||||
return args;
|
||||
@@ -160,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;
|
||||
@@ -251,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) {
|
||||
@@ -273,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) {
|
||||
@@ -281,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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -325,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) {
|
||||
@@ -337,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";
|
||||
@@ -349,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;
|
||||
@@ -379,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) {
|
||||
@@ -398,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;
|
||||
@@ -406,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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -536,6 +582,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
// 立即清空缓冲区,确保切歌瞬间静音 (
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.ffmpegDiagnostics = null;
|
||||
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
@@ -550,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 */ }
|
||||
}
|
||||
|
||||
@@ -573,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 {
|
||||
@@ -657,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"
|
||||
@@ -827,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().
|
||||
|
||||
@@ -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";
|
||||
@@ -1590,3 +1591,348 @@ describe("BotInstance Bilibili multi-P resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+152
-9
@@ -13,7 +13,13 @@ import {
|
||||
canRunCommand,
|
||||
type ParsedCommand,
|
||||
} from "./commands.js";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import {
|
||||
parseSongRef,
|
||||
parseSelectionIndex,
|
||||
parsePlaylistRef,
|
||||
findShareShortLink,
|
||||
resolveShareLink,
|
||||
} from "./song-ref.js";
|
||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
|
||||
@@ -193,6 +199,8 @@ export class BotInstance extends EventEmitter {
|
||||
private lastSearchResults: Song[] = [];
|
||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||
private effectiveDuration: number | undefined;
|
||||
/** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */
|
||||
private streamRecovery: { song: QueuedSong; attempts: number; position: number; session: number; pauseRequested: boolean; inFlight: boolean } | null = null;
|
||||
private playGate: Promise<unknown> = Promise.resolve();
|
||||
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||
* null when the wired provider has no reporting capability. */
|
||||
@@ -322,10 +330,30 @@ export class BotInstance extends EventEmitter {
|
||||
});
|
||||
|
||||
this.player.on("trackEnd", () => {
|
||||
this.logger.debug("Track ended, advancing queue");
|
||||
this.playNext().catch((err) => {
|
||||
this.logger.error({ err }, "playNext failed after trackEnd");
|
||||
});
|
||||
const endedSong = this.queue.current();
|
||||
const endedSession = this.player.getPlaybackSessionId();
|
||||
this.resumeInterruptedStream()
|
||||
.catch((err) => {
|
||||
this.logger.warn({ err }, "Stream resume failed");
|
||||
return false;
|
||||
})
|
||||
.then((resumed) => {
|
||||
if (resumed) return;
|
||||
// A pending command may replace, stop, or restart the same queue
|
||||
// song before this continuation. Only advance the session that ended.
|
||||
if (
|
||||
!this.connected ||
|
||||
this.queue.current() !== endedSong ||
|
||||
this.player.getPlaybackSessionId() !== endedSession ||
|
||||
this.player.getState() !== "idle" ||
|
||||
(this.streamRecovery?.song === endedSong && this.streamRecovery.pauseRequested)
|
||||
) return;
|
||||
this.logger.debug("Track ended, advancing queue");
|
||||
return this.playNext();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error({ err }, "playNext failed after trackEnd");
|
||||
});
|
||||
});
|
||||
|
||||
this.player.on("error", (err: Error) => {
|
||||
@@ -456,11 +484,16 @@ export class BotInstance extends EventEmitter {
|
||||
this.voiceDucking.removeSpeaker(event.id);
|
||||
void this.refreshOccupancy();
|
||||
});
|
||||
this.tsClient.on("clientMoved", (event: { id: number }) => {
|
||||
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
|
||||
if (event.id === this.tsClient.getClientId()) {
|
||||
// Moving the bot invalidates every activity deadline from its old
|
||||
// channel even if no individual leave events arrive.
|
||||
this.voiceDucking.reset(false);
|
||||
// Carry the now-playing channel description over to the new
|
||||
// channel instead of leaving it stale in the old one (#159).
|
||||
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
|
||||
this.logger.warn({ err }, "Channel description move update failed");
|
||||
});
|
||||
} else {
|
||||
this.voiceDucking.removeSpeaker(event.id);
|
||||
}
|
||||
@@ -1116,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);
|
||||
@@ -1338,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) =>
|
||||
@@ -1350,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"));
|
||||
@@ -1513,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);
|
||||
@@ -1522,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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Client, generateIdentity } from "@honeybbq/teamspeak-client";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import { TS6HttpQuery } from "../ts-protocol/http-query.js";
|
||||
import type { TS3Client } from "../ts-protocol/client.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
|
||||
@@ -14,6 +16,9 @@ function makeMockTs(): TS3Client & {
|
||||
get clearCalls() { return clears; },
|
||||
getHost: () => "127.0.0.1",
|
||||
getHttpQuery: () => null,
|
||||
getClientId: () => 17,
|
||||
getChannelId: () => 5n,
|
||||
execCommand: vi.fn().mockResolvedValue(undefined),
|
||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -202,3 +207,236 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotProfileManager channel description follows the bot (#159)", () => {
|
||||
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
|
||||
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
|
||||
let channelEdits: () => string[];
|
||||
|
||||
beforeEach(() => {
|
||||
ts = makeMockTs() as any;
|
||||
ts.cid = 5n;
|
||||
(ts as any).getChannelId = () => ts.cid;
|
||||
channelEdits = () =>
|
||||
(ts.execCommand as any).mock.calls
|
||||
.map((c: any[]) => c[0] as string)
|
||||
.filter((cmd: string) => cmd.startsWith("channeledit"));
|
||||
});
|
||||
|
||||
it("clears the old channel and fills the new one when moved while playing", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(channelEdits()).toEqual([
|
||||
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
|
||||
]);
|
||||
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
|
||||
const edits = channelEdits();
|
||||
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
|
||||
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
|
||||
});
|
||||
|
||||
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
await pm.onSongChange(null);
|
||||
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
|
||||
});
|
||||
|
||||
it("a move while idle touches no channel description", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(channelEdits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a move is ignored when the channel description feature is off", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
ts.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(channelEdits()).toEqual([]);
|
||||
});
|
||||
|
||||
it("an event for the channel the description is already in is a no-op", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
|
||||
await pm.onSongChange(fakeSong);
|
||||
await pm.onChannelMoved(5n);
|
||||
expect(channelEdits()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeHttpProfile(partial: Partial<typeof cfgOff> = {}) {
|
||||
const ts = makeMockTs() as any;
|
||||
const state = { clid: 17, cid: 5n };
|
||||
const descriptions = new Map<number, string>();
|
||||
const http = new TS6HttpQuery({ host: "127.0.0.1", port: 10080 });
|
||||
const request = vi.spyOn(http, "request").mockImplementation(async (_method, path, body) => {
|
||||
if (path.includes("clientlist")) {
|
||||
return { status: 200, body: { body: [{ clid: String(state.clid), cid: String(state.cid) }], status: { code: 0, message: "ok" } } };
|
||||
}
|
||||
if (path.includes("channeledit")) descriptions.set(Number(body!.cid), String(body!.channel_description));
|
||||
return { status: 200, body: { status: { code: 0, message: "ok" } } };
|
||||
});
|
||||
ts.getHttpQuery = () => http;
|
||||
ts.getClientId = () => state.clid;
|
||||
ts.getChannelId = () => state.cid;
|
||||
const logger: any = { child: () => logger, info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const pm = new BotProfileManager(ts, logger, { ...cfgOff, ...partial }, "Bot");
|
||||
return { pm, ts, state, http, request, descriptions, logger };
|
||||
}
|
||||
|
||||
describe("BotProfileManager checked TS6 profile lifecycle", () => {
|
||||
it("clears the old channel through HTTP Query when moved, even without full-client edit permission", async () => {
|
||||
const { pm, ts, state, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.execCommand.mockRejectedValue(new Error("insufficient client permissions"));
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(descriptions.get(5)).toContain("X - Y");
|
||||
state.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
expect(ts.sendCommandNoWait).not.toHaveBeenCalled();
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards an old client-list reply after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
state.cid = 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("channeledit"))).toEqual([]);
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("does not restore the previous remembered channel when a write completes after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 200, body: { status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when playback stops", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onSongChange(null);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when the bot is moved", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onChannelMoved(9n);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.has(5)).toBe(false);
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
});
|
||||
|
||||
it("does not disable the new connection after an old write returns a permission failure", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await update;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
});
|
||||
|
||||
it("resolves an unknown channel and sends raw newlines with one targeted description update", async () => {
|
||||
const { pm, ts, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
state.cid = 5n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("clientedit"))).toEqual([
|
||||
["POST", "/1/clientedit?sid=1", { clid: 17, client_description: "X - Y [Z]" }],
|
||||
]);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 5, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve or write a disconnected client", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
state.clid = 0;
|
||||
state.cid = 0n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports HTTP lookup permission errors once and retries after reconnect", async () => {
|
||||
const { pm, ts, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
request.mockResolvedValue({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await pm.onSongChange(fakeSong);
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks self clientupdate permission responses and retries only after reconnect", async () => {
|
||||
const { pm, ts, request, logger } = makeHttpProfile({ nicknameEnabled: true, awayStatusEnabled: true });
|
||||
const client: any = new Client(generateIdentity(0), "127.0.0.1:9987", "Bot");
|
||||
const commands: string[] = [];
|
||||
client.handler.sendPacket = vi.fn((_type, data: Buffer) => {
|
||||
const command = data.toString();
|
||||
commands.push(command);
|
||||
const returnCode = command.match(/return_code=(\d+)/)?.[1];
|
||||
client.handler.onPacket({ typeFlagged: 2, data: Buffer.from(`error id=2568 msg=insufficient\\sclient\\spermissions${returnCode ? ` return_code=${returnCode}` : ""}`) });
|
||||
});
|
||||
ts.sendCommandNoWait.mockImplementation((command: string) => client.sendCommandNoWait(command));
|
||||
ts.execCommand.mockImplementation((command: string) => client.execCommand(command));
|
||||
await pm.onSongChange(null);
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toMatch(/^clientupdate client_nickname=Bot client_away=1 client_away_message=等待播放 return_code=\d+$/);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(logger.info.mock.calls.some((call: any[]) => call[1] === "Client properties updated (nickname + away)")).toBe(false);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+216
-72
@@ -13,6 +13,13 @@ const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
interface ProfileUpdateContext {
|
||||
generation: number;
|
||||
channelGeneration: number;
|
||||
clientId: number;
|
||||
httpQuery: ReturnType<TS3Client["getHttpQuery"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
@@ -32,6 +39,13 @@ export class BotProfileManager {
|
||||
* pushed immediately (idle) or wait for the next stop event (playing).
|
||||
*/
|
||||
private currentSong: QueuedSong | null = null;
|
||||
/**
|
||||
* Channel whose description currently holds our now-playing text, or null
|
||||
* if we have not written one. Remembered so that when the bot is moved we
|
||||
* can still clean up the channel it was taken out of (#159) — by then
|
||||
* getChannelId() already reports the new channel.
|
||||
*/
|
||||
private channelDescCid: bigint | null = null;
|
||||
|
||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||
private permDenied = {
|
||||
@@ -50,6 +64,8 @@ export class BotProfileManager {
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
/** Channel moves supersede channel writes without cancelling avatar work. */
|
||||
private channelGeneration = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
@@ -112,20 +128,25 @@ export class BotProfileManager {
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = song;
|
||||
const context = this.createUpdateContext();
|
||||
|
||||
// 1. Avatar first — file transfer uses its own response tracker and
|
||||
// must run before sendCommandNoWait calls whose orphaned responses
|
||||
// could confuse the command matcher.
|
||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||
if (this.generation !== gen) return; // superseded
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 2. Checked clientupdate sent by the visible client itself.
|
||||
await this.updateClientProperties(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
await this.updateDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 4. Checked channel description update.
|
||||
await this.updateChannelDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
@@ -133,7 +154,10 @@ export class BotProfileManager {
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = null;
|
||||
// Channel ids are per-server; never carry one across a (re)connect.
|
||||
this.channelDescCid = null;
|
||||
this.permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
@@ -150,6 +174,32 @@ export class BotProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the bot itself has been moved to another channel (#159).
|
||||
* Clears the now-playing text from the channel it left and, if a song is
|
||||
* playing, writes it to the channel it is in now.
|
||||
*/
|
||||
async onChannelMoved(newChannelId: bigint): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
const oldChannelId = this.channelDescCid;
|
||||
if (oldChannelId === newChannelId) return;
|
||||
this.channelGeneration++;
|
||||
const context = this.createUpdateContext();
|
||||
const song = this.currentSong;
|
||||
try {
|
||||
if (oldChannelId !== null) {
|
||||
if (!await this.writeChannelDescription(oldChannelId, "", context)) return;
|
||||
this.channelDescCid = null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
return;
|
||||
}
|
||||
if (song) {
|
||||
await this.updateChannelDescription(song, context, newChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): ProfileConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
@@ -252,53 +302,56 @@ export class BotProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
private async updateDescription(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
|
||||
const clid = context.clientId;
|
||||
if (clid <= 0) return;
|
||||
|
||||
const httpQuery = context.httpQuery;
|
||||
|
||||
if (httpQuery) {
|
||||
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
|
||||
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
|
||||
// be misreported as success.
|
||||
const result = await httpQuery.clientUpdate({ client_description: text });
|
||||
this.logger.info({ status: result.status }, "Description updated");
|
||||
// IMPORTANT:
|
||||
// clientUpdate() would modify the HTTP Query/serveradmin client.
|
||||
// Explicitly edit the real visible music client instead.
|
||||
const result = await httpQuery.clientEdit(clid, {
|
||||
client_description: text,
|
||||
});
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info(
|
||||
{ status: result.status, clid },
|
||||
"Description updated",
|
||||
);
|
||||
} else {
|
||||
// clientupdate rejects client_description (error 1538).
|
||||
// Use clientedit on our own clid instead — this is what
|
||||
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||
const clid = this.tsClient.getClientId();
|
||||
if (clid <= 0) return;
|
||||
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||
// remaining profile updates (channeledit, now-playing msg).
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
this.logger.info("Description updated");
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info({ clid }, "Description updated");
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
if (this.isCurrentUpdate(context)) this.handleFeatureError("description", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send a single `clientupdate` command that sets nickname
|
||||
* and away status together, avoiding multiple round-trips that can
|
||||
* cause command-queue timeouts on the TS3 protocol.
|
||||
*
|
||||
* Values are collected as raw strings/numbers. The TS6 HTTP path
|
||||
* forwards them as JSON (the server expects real spaces, not `\s`);
|
||||
* the TS3 wire path escapes them on the fly. Previously the code
|
||||
* escaped upfront and then split the escaped string to build the
|
||||
* JSON body, so TS6 received literal backslashes and silently
|
||||
* rejected the update.
|
||||
* and away status together. The full client sends this command on both
|
||||
* TS3 and TS6, with a return code so permission failures are observable.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
private async updateClientProperties(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.isCurrentUpdate(context) || context.clientId <= 0) return;
|
||||
const rawProps: Record<string, string | number> = {};
|
||||
|
||||
// --- Nickname ---
|
||||
@@ -319,39 +372,38 @@ export class BotProfileManager {
|
||||
rawProps.client_away = 0;
|
||||
} else {
|
||||
rawProps.client_away = 1;
|
||||
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
|
||||
rawProps.client_away_message = "等待播放";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rawProps).length === 0) return;
|
||||
|
||||
try {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
|
||||
const result = await httpQuery.clientUpdate(rawProps);
|
||||
this.logger.info(
|
||||
{ status: result.status, props: Object.keys(rawProps) },
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
} else {
|
||||
// TS3 wire protocol: escape string values inline.
|
||||
// sendCommandNoWait: the TS3 full-client protocol often
|
||||
// doesn't return a timely error response for clientupdate,
|
||||
// causing execCommand to time out after 10s.
|
||||
const parts = Object.entries(rawProps).map(([k, v]) =>
|
||||
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
|
||||
);
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||
this.logger.info(
|
||||
{ props: Object.keys(rawProps) },
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
}
|
||||
// clientupdate modifies whichever connection sends the command.
|
||||
// Therefore it must be sent by the real full client, NOT HTTP Query.
|
||||
const parts = Object.entries(rawProps).map(([key, value]) =>
|
||||
typeof value === "string"
|
||||
? `${key}=${escapeTS3(value)}`
|
||||
: `${key}=${value}`,
|
||||
);
|
||||
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(`clientupdate ${parts.join(" ")}`),
|
||||
5000,
|
||||
);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
clid: context.clientId,
|
||||
props: Object.keys(rawProps),
|
||||
},
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
} catch (err) {
|
||||
// Flag both features on permission error
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
if (rawProps.client_nickname !== undefined) this.handleFeatureError("nickname", err);
|
||||
if (rawProps.client_away !== undefined) this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,33 +452,106 @@ export class BotProfileManager {
|
||||
return str.slice(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||
private async updateChannelDescription(
|
||||
song: QueuedSong | null,
|
||||
context: ProfileUpdateContext,
|
||||
targetChannelId?: bigint,
|
||||
): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
if (!this.isCurrentChannelUpdate(context) || context.clientId <= 0) return;
|
||||
|
||||
try {
|
||||
const channelId = this.tsClient.getChannelId();
|
||||
if (channelId === 0n) return; // unknown channel
|
||||
// A stop already knows which channel to clear if a write succeeded.
|
||||
// Avoid a needless client-list lookup that could prevent that cleanup.
|
||||
let channelId = !song && this.channelDescCid !== null
|
||||
? this.channelDescCid
|
||||
: targetChannelId ?? this.tsClient.getChannelId();
|
||||
|
||||
// TS6 full-client may report channelID() as 0 even after the
|
||||
// visible music client has already joined a channel.
|
||||
// Fall back to HTTP Query and resolve our real clid -> cid.
|
||||
if (channelId === 0n) {
|
||||
const httpQuery = context.httpQuery;
|
||||
const clid = context.clientId;
|
||||
|
||||
if (httpQuery && clid > 0) {
|
||||
const result = await httpQuery.clientList();
|
||||
if (!this.isCurrentChannelUpdate(context)) return;
|
||||
|
||||
const payload = result.body as {
|
||||
body?: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
const me = payload?.body?.find(
|
||||
(client) => Number(client.clid) === clid,
|
||||
);
|
||||
|
||||
if (me?.cid) {
|
||||
channelId = BigInt(me.cid);
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
clid,
|
||||
cid: channelId.toString(),
|
||||
},
|
||||
"Resolved channel ID via HTTP Query",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=`,
|
||||
);
|
||||
if (channelId <= 0n) return;
|
||||
if (await this.writeChannelDescription(channelId, "", context)) this.channelDescCid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId <= 0n) return;
|
||||
|
||||
const lines = [
|
||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||
`♪ 正在播放: ${song.name} - ${song.artist}`,
|
||||
`专辑: ${song.album}`,
|
||||
`平台: ${song.platform}`,
|
||||
];
|
||||
const desc = lines.join("\\n");
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
);
|
||||
|
||||
// HTTP Query uses a normal JSON string, so use real newlines here.
|
||||
const desc = lines.join("\n");
|
||||
|
||||
if (await this.writeChannelDescription(channelId, desc, context)) this.channelDescCid = channelId;
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Both move cleanup and ordinary writes use the same checked transport. */
|
||||
private async writeChannelDescription(
|
||||
channelId: bigint,
|
||||
description: string,
|
||||
context: ProfileUpdateContext,
|
||||
): Promise<boolean> {
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
let status: number | undefined;
|
||||
if (context.httpQuery) {
|
||||
const result = await context.httpQuery.channelEdit(Number(channelId), {
|
||||
channel_description: description,
|
||||
});
|
||||
status = result.status;
|
||||
} else {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(description)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
this.logger.info(
|
||||
{ status, cid: channelId.toString() },
|
||||
description ? "Channel description updated" : "Channel description cleared",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
@@ -439,6 +564,25 @@ export class BotProfileManager {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private createUpdateContext(): ProfileUpdateContext {
|
||||
return {
|
||||
generation: this.generation,
|
||||
channelGeneration: this.channelGeneration,
|
||||
clientId: this.tsClient.getClientId(),
|
||||
httpQuery: this.tsClient.getHttpQuery(),
|
||||
};
|
||||
}
|
||||
|
||||
private isCurrentUpdate(context: ProfileUpdateContext): boolean {
|
||||
return context.generation === this.generation &&
|
||||
context.clientId === this.tsClient.getClientId() &&
|
||||
context.httpQuery === this.tsClient.getHttpQuery();
|
||||
}
|
||||
|
||||
private isCurrentChannelUpdate(context: ProfileUpdateContext): boolean {
|
||||
return this.isCurrentUpdate(context) && context.channelGeneration === this.channelGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
|
||||
|
||||
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||
it("returns null for a plain search term", () => {
|
||||
@@ -120,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
|
||||
expect(parseSelectionIndex("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
|
||||
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
|
||||
expect(parsePlaylistRef("华语经典")).toBeNull();
|
||||
expect(parsePlaylistRef("2829883282")).toBeNull();
|
||||
expect(parsePlaylistRef("")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
|
||||
});
|
||||
|
||||
it("does not mistake a NetEase userid= for the playlist id", () => {
|
||||
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
|
||||
it("parses QQ Music playlist URLs", () => {
|
||||
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("parses YouTube playlist URLs by their list= id", () => {
|
||||
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
|
||||
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
|
||||
});
|
||||
|
||||
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
|
||||
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
|
||||
});
|
||||
|
||||
it("finds the link inside an app's share text", () => {
|
||||
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findShareShortLink (#160)", () => {
|
||||
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
|
||||
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
|
||||
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
|
||||
});
|
||||
|
||||
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
|
||||
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
|
||||
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
|
||||
expect(findShareShortLink("华语经典")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShareLink (#160)", () => {
|
||||
it("returns the redirect target", async () => {
|
||||
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
|
||||
});
|
||||
|
||||
it("returns null when there is no redirect or the request fails", async () => {
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
|
||||
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
||||
* so same-name songs can be disambiguated instead of always getting the single
|
||||
@@ -98,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export interface PlaylistRef {
|
||||
id: string;
|
||||
platform: "netease" | "qq" | "youtube";
|
||||
}
|
||||
|
||||
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
|
||||
function stripUrlBBCode(text: string): string {
|
||||
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
|
||||
* share text. The platform comes from the URL, so a QQ link works without
|
||||
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
|
||||
* the caller handles as before.
|
||||
*/
|
||||
export function parsePlaylistRef(raw: string): PlaylistRef | null {
|
||||
const q = stripUrlBBCode(raw ?? "").trim();
|
||||
if (!q) return null;
|
||||
|
||||
if (/music\.163\.com/i.test(q)) {
|
||||
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "netease" };
|
||||
}
|
||||
|
||||
if (/y\.qq\.com/i.test(q)) {
|
||||
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "qq" };
|
||||
}
|
||||
|
||||
if (/youtube\.com|youtu\.be/i.test(q)) {
|
||||
const m = /[?&]list=([\w-]+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "youtube" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
|
||||
* short link — what the phone apps copy. Only these hosts are recognized so
|
||||
* the bot never fetches an arbitrary user-supplied URL.
|
||||
*/
|
||||
export function findShareShortLink(raw: string): string | null {
|
||||
const q = stripUrlBBCode(raw ?? "");
|
||||
const m =
|
||||
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
|
||||
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
|
||||
|
||||
const redirectGet: RedirectGet = async (url) => {
|
||||
const res = await axios.get(url, {
|
||||
maxRedirects: 0,
|
||||
timeout: 5000,
|
||||
validateStatus: () => true,
|
||||
responseType: "stream",
|
||||
});
|
||||
res.data?.destroy?.();
|
||||
const location = res.headers.location;
|
||||
return { status: res.status, location: typeof location === "string" ? location : undefined };
|
||||
};
|
||||
|
||||
/** Follow a share short link one hop. Returns the target URL, or null. */
|
||||
export async function resolveShareLink(
|
||||
url: string,
|
||||
get: RedirectGet = redirectGet,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { status, location } = await get(url);
|
||||
if (status < 300 || status >= 400 || !location) return null;
|
||||
return new URL(location, url).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
// Real net/Koa fire the listening callback on a later tick, after the
|
||||
// caller has captured the returned server handle.
|
||||
if (cb) setImmediate(cb);
|
||||
return server;
|
||||
},
|
||||
};
|
||||
return { default: app };
|
||||
const state = vi.hoisted(() => ({ fork: vi.fn(), probes: [] as EventEmitter[], probeAutomatically: true, portFree: true, directImports: 0 }));
|
||||
vi.mock("node:child_process", () => ({ fork: state.fork }));
|
||||
vi.mock("node:net", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
return { default: { createServer: () => {
|
||||
const probe = new EventEmitter() as EventEmitter & { close(done: () => void): void; listen(): void };
|
||||
probe.close = (done) => queueMicrotask(done);
|
||||
probe.listen = () => {
|
||||
state.probes.push(probe);
|
||||
if (state.probeAutomatically) queueMicrotask(() => probe.emit(state.portFree ? "listening" : "error"));
|
||||
};
|
||||
return probe;
|
||||
} } };
|
||||
});
|
||||
vi.mock("@sansenjian/qq-music-api", () => {
|
||||
state.directImports++;
|
||||
return { default: { listen: () => { throw new Error("sidecar imported in parent"); } } };
|
||||
});
|
||||
vi.mock("NeteaseCloudMusicApi", () => {
|
||||
state.directImports++;
|
||||
return { server: { serveNcmApi: () => { throw new Error("sidecar imported in parent"); } } };
|
||||
});
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
connected = true;
|
||||
exitOnStop = true;
|
||||
send = vi.fn((message: { type: string }) => {
|
||||
if (message.type === "stop" && this.exitOnStop) queueMicrotask(() => this.finish(0, null));
|
||||
return true;
|
||||
});
|
||||
kill = vi.fn((signal: string = "SIGTERM") => { queueMicrotask(() => this.finish(null, signal)); return true; });
|
||||
finish(code: number | null, signal: string | null) { this.connected = false; this.emit("exit", code, signal); }
|
||||
}
|
||||
|
||||
describe("describeQqApiStartupError", () => {
|
||||
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
||||
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
|
||||
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
|
||||
expect(hint).toMatch(/~2\.4\.0/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
it("retains ESM diagnostics by code and message", () => {
|
||||
expect(describeQqApiStartupError({ code: "ERR_REQUIRE_ESM" })).toMatch(/~2\.4\.0/);
|
||||
expect(describeQqApiStartupError(new Error("require() of ES Module is unsupported"))).toMatch(/ERR_REQUIRE_ESM/);
|
||||
});
|
||||
|
||||
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
|
||||
const hint = describeQqApiStartupError(
|
||||
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
|
||||
);
|
||||
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
|
||||
});
|
||||
|
||||
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
|
||||
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
|
||||
expect(hint).toMatch(/Node >=20\.17/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
|
||||
expect(describeQqApiStartupError(undefined)).toBeNull();
|
||||
expect(describeQqApiStartupError(null)).toBeNull();
|
||||
it("retains engine diagnostics and ignores unrelated failures", () => {
|
||||
expect(describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"))).toMatch(/Node >=20\.17/);
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
|
||||
// the same port the client base URL targets (config.qqMusicApiPort). A stale
|
||||
// build once bound 3300 while the client requested 3200, silently breaking the
|
||||
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
|
||||
describe("createApiServerManager — QQ sidecar port binding", () => {
|
||||
const noopLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
trace() {},
|
||||
fatal() {},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("embedded API child lifecycle", () => {
|
||||
let children: FakeChild[];
|
||||
let logger: Logger;
|
||||
let manager: ReturnType<typeof createApiServerManager>;
|
||||
let automaticReady: boolean;
|
||||
const options = { neteasePort: 39218, qqMusicPort: 39217, neteaseEnabled: true, qqEnabled: true };
|
||||
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
||||
beforeEach(() => {
|
||||
mockState.listenCalls = [];
|
||||
vi.useRealTimers(); children = []; automaticReady = true;
|
||||
state.probes = []; state.portFree = true; state.probeAutomatically = true; state.fork.mockReset();
|
||||
state.fork.mockImplementation((_entry: string, args: string[]) => {
|
||||
const child = new FakeChild(); children.push(child);
|
||||
if (automaticReady) queueMicrotask(() => child.emit("message", { type: "ready", provider: args[0], port: Number(args[1]) }));
|
||||
return child as unknown as ChildProcess;
|
||||
});
|
||||
logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger;
|
||||
manager = createApiServerManager(options, logger);
|
||||
});
|
||||
afterEach(async () => { manager.stop(); await flush(); vi.useRealTimers(); });
|
||||
|
||||
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
|
||||
const port = 39217; // uncommon port to avoid clashing with a real instance
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
it("isolates both APIs with ignored stdio, configured ports and IPC", async () => {
|
||||
await manager.start();
|
||||
manager.stop();
|
||||
|
||||
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
expect(state.fork.mock.calls.map((call) => call[1])).toEqual([["netease", "39218"], ["qq", "39217"]]);
|
||||
for (const call of state.fork.mock.calls) {
|
||||
expect(String(call[0])).toMatch(/api-server-child\.ts$/);
|
||||
expect(call[2].stdio).toEqual(["ignore", "ignore", "ignore", "ipc"]);
|
||||
expect(call[2].execArgv).not.toContain("--eval");
|
||||
expect(call[2].execArgv).not.toContain("--input-type=module");
|
||||
}
|
||||
expect(state.directImports).toBe(0);
|
||||
expect(manager.getNeteaseBaseUrl()).toBe("http://127.0.0.1:39218");
|
||||
expect(manager.getQQMusicBaseUrl()).toBe("http://127.0.0.1:39217");
|
||||
});
|
||||
|
||||
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
|
||||
const port = 39219;
|
||||
const previous = process.env.PORT;
|
||||
// Simulate a hosting platform / compose file injecting a stray PORT that
|
||||
// must NOT leak into the QQ sidecar's chosen port.
|
||||
process.env.PORT = "39999";
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
it("preserves provider gating and externally bound port reuse", async () => {
|
||||
manager = createApiServerManager({ ...options, neteaseEnabled: false, qqEnabled: false }, logger);
|
||||
await manager.start(); expect(state.probes).toHaveLength(0); expect(state.fork).not.toHaveBeenCalled();
|
||||
state.portFree = false;
|
||||
manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
await manager.start(); expect(state.fork).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, expect.stringContaining("reusing"));
|
||||
});
|
||||
it("inherits tsx loader arguments without unrelated parent runner flags", async () => {
|
||||
const previous = process.execArgv;
|
||||
process.execArgv = ["--require", "C:\\app\\node_modules\\tsx\\dist\\preflight.cjs", "--import", "file:///app/node_modules/tsx/dist/loader.mjs", "--eval", "synthetic-evaluation", "--conditions", "vitest", "--input-type=module", "--inspect"];
|
||||
try {
|
||||
await manager.start();
|
||||
// The sidecar follows qqMusicPort, never the injected PORT.
|
||||
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||
// The injected PORT is restored so nothing else in the process is affected.
|
||||
expect(process.env.PORT).toBe("39999");
|
||||
} finally {
|
||||
manager.stop();
|
||||
if (previous === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = previous;
|
||||
}
|
||||
expect(state.fork.mock.calls[0][2].execArgv).toEqual(process.execArgv.slice(0, 4));
|
||||
} finally { process.execArgv = previous; }
|
||||
});
|
||||
|
||||
it("leaves an absent PORT env unset after importing the sidecar", async () => {
|
||||
const port = 39221;
|
||||
const previous = process.env.PORT;
|
||||
delete process.env.PORT;
|
||||
const manager = createApiServerManager(
|
||||
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||
noopLogger
|
||||
);
|
||||
try {
|
||||
await manager.start();
|
||||
// Was unset before importing — must be unset again, no leaked override.
|
||||
expect(process.env.PORT).toBeUndefined();
|
||||
} finally {
|
||||
manager.stop();
|
||||
if (previous === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = previous;
|
||||
it("does not duplicate concurrent or repeated starts", async () => {
|
||||
await Promise.all([manager.start(), manager.start()]); await manager.start();
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("fences a stop during pending port preflight", async () => {
|
||||
state.probeAutomatically = false;
|
||||
const starting = manager.start(); await flush(); manager.stop();
|
||||
state.probes[0].emit("listening"); await starting;
|
||||
expect(state.fork).not.toHaveBeenCalled();
|
||||
});
|
||||
it("cancels a pending handshake and ignores its late ready", async () => {
|
||||
automaticReady = false;
|
||||
const starting = manager.start(); await flush(); expect(children).toHaveLength(1);
|
||||
manager.stop(); children[0].emit("message", { type: "ready", provider: "netease", port: 39218 }); await starting;
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
expect(state.fork).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).not.toHaveBeenCalledWith({ port: 39218 }, "NetEase Cloud Music API started");
|
||||
});
|
||||
it("waits for a cancelled preflight to release its probe before restart", async () => {
|
||||
state.probeAutomatically = false;
|
||||
const first = manager.start(); await flush(); manager.stop();
|
||||
const restarting = manager.start(); await flush();
|
||||
expect(state.probes).toHaveLength(1);
|
||||
state.probeAutomatically = true; state.probes[0].emit("listening");
|
||||
await Promise.all([first, restarting]);
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("waits for old children to exit before restart", async () => {
|
||||
await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
|
||||
const restarting = manager.start(); await flush(); expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
children.slice(0, 2).forEach((child) => child.finish(0, null)); await restarting;
|
||||
expect(state.fork).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it("reports unexpected post-ready exits with safe fields", async () => {
|
||||
await manager.start(); children[1].finish(7, "SIGTERM");
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, code: 7, signal: "SIGTERM" }, expect.stringContaining("exited unexpectedly"));
|
||||
});
|
||||
it("retains static QQ diagnostics and discards arbitrary IPC fields", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("message", { type: "error", provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM", message: "synthetic-credential", stack: "synthetic-credential" }); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "esm", code: "ERR_REQUIRE_ESM" }, expect.stringContaining("ERR_REQUIRE_ESM"));
|
||||
expect(JSON.stringify([...(logger.error as ReturnType<typeof vi.fn>).mock.calls, ...(logger.warn as ReturnType<typeof vi.fn>).mock.calls])).not.toContain("synthetic-credential");
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
});
|
||||
it("ignores a ready message for a different provider or port", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("message", { type: "ready", provider: "netease", port: 39217 });
|
||||
children[0].emit("message", { type: "ready", provider: "qq", port: 39999 });
|
||||
await flush();
|
||||
expect(logger.info).not.toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
|
||||
children[0].emit("message", { type: "ready", provider: "qq", port: 39217 }); await starting;
|
||||
expect(logger.info).toHaveBeenCalledWith({ port: 39217 }, "QQ Music API started");
|
||||
});
|
||||
it("cleans up a failed fork that closes without an exit event", async () => {
|
||||
automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush();
|
||||
children[0].emit("error", Object.assign(new Error("synthetic-credential"), { code: "ENOENT" }));
|
||||
children[0].emit("close", null, null); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "startup", code: "ENOENT" }, expect.stringContaining("start"));
|
||||
automaticReady = true; await manager.start();
|
||||
expect(state.fork).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("times out and terminates a silent child", async () => {
|
||||
vi.useFakeTimers(); automaticReady = false; manager = createApiServerManager({ ...options, neteaseEnabled: false }, logger);
|
||||
const starting = manager.start(); await flush(); await vi.advanceTimersByTimeAsync(30000); await starting;
|
||||
expect(logger.error).toHaveBeenCalledWith({ provider: "qq", port: 39217, category: "timeout" }, expect.stringContaining("start"));
|
||||
expect(children[0].send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
|
||||
});
|
||||
it("forces shutdown if a child ignores stop", async () => {
|
||||
vi.useFakeTimers(); await manager.start(); children.forEach((child) => { child.exitOnStop = false; }); manager.stop();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(children.every((child) => child.kill.mock.calls.length > 0)).toBe(true);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
it("escalates to SIGKILL if stop and SIGTERM are ignored", async () => {
|
||||
vi.useFakeTimers(); await manager.start();
|
||||
for (const child of children) {
|
||||
child.exitOnStop = false;
|
||||
child.kill.mockImplementation((signal: string = "SIGTERM") => {
|
||||
if (signal === "SIGKILL") queueMicrotask(() => child.finish(null, signal));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
manager.stop(); await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(children.every((child) => child.kill.mock.calls.some(([signal]) => signal === "SIGKILL"))).toBe(true);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+160
-178
@@ -1,16 +1,13 @@
|
||||
import net from "node:net";
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { Server } from "node:http";
|
||||
import { getSafeApiStartupError, isApiPortFree, safeApiErrorCode, type ApiProvider, type SafeApiStartupError } from "./api-server-runtime.js";
|
||||
|
||||
export interface ApiServerOptions {
|
||||
neteasePort: number;
|
||||
qqMusicPort: number;
|
||||
/** Provider gating (#enabledProviders): when false, the corresponding
|
||||
* embedded sidecar API server is never started and its port never bound. */
|
||||
neteaseEnabled?: boolean;
|
||||
qqEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiServerManager {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
@@ -18,196 +15,181 @@ export interface ApiServerManager {
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
|
||||
* actionable operator guidance, or null when it isn't a recognised
|
||||
* dependency/runtime mismatch. Exported for testing.
|
||||
*
|
||||
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
|
||||
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
|
||||
* that needs Node >=20.17 — either way the embedded server never binds, so
|
||||
* every QQ request fails downstream with ECONNREFUSED on the API port.
|
||||
*/
|
||||
export function describeQqApiStartupError(err: unknown): string | null {
|
||||
const e = (err ?? {}) as { code?: string; message?: string };
|
||||
const code = String(e.code ?? "");
|
||||
const msg = String(e.message ?? "");
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
|
||||
return (
|
||||
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
|
||||
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
|
||||
);
|
||||
}
|
||||
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
|
||||
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
}
|
||||
const { category } = getSafeApiStartupError(err);
|
||||
if (category === "esm") return "an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall";
|
||||
if (category === "node-engine") return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => {
|
||||
server.close(() => resolve(false));
|
||||
});
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, "127.0.0.1");
|
||||
});
|
||||
/** Carry only tsx loader arguments into a source child. CLI evaluation,
|
||||
* inspector and test-runner flags have unrelated meanings in a fork. */
|
||||
function childExecArgv(source: boolean): string[] {
|
||||
if (!source) return [];
|
||||
const args: string[] = [];
|
||||
for (let i = 0; i < process.execArgv.length; i++) {
|
||||
const arg = process.execArgv[i];
|
||||
if (arg === "--import" || arg === "--require" || arg === "-r") {
|
||||
const value = process.execArgv[++i];
|
||||
if (value && (value === "tsx" || /[/\\]tsx[/\\]/.test(value))) args.push(arg, value);
|
||||
} else if (arg.startsWith("--import=") && (arg === "--import=tsx" || /[/\\]tsx[/\\]/.test(arg))) args.push(arg);
|
||||
}
|
||||
return args.length ? args : ["--import", "tsx"];
|
||||
}
|
||||
|
||||
export function createApiServerManager(
|
||||
options: ApiServerOptions,
|
||||
logger: Logger
|
||||
): ApiServerManager {
|
||||
let neteaseServer: Server | null = null;
|
||||
let qqMusicServer: Server | null = null;
|
||||
class StartupFailure extends Error {
|
||||
constructor(readonly details: SafeApiStartupError) { super("Embedded music API startup failed"); }
|
||||
}
|
||||
interface ManagedChild {
|
||||
child: ChildProcess;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
const STARTUP_TIMEOUT_MS = 15000;
|
||||
const ERROR_CATEGORIES = new Set(["esm", "node-engine", "port-in-use", "startup"]);
|
||||
|
||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||
export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
|
||||
const children = new Map<ApiProvider, ManagedChild>();
|
||||
let generation = 0;
|
||||
let starting: Promise<void> | null = null;
|
||||
let stopping: Promise<void> = Promise.resolve();
|
||||
|
||||
function launch(provider: ApiProvider, port: number, launchGeneration: number): Promise<void> {
|
||||
const source = import.meta.url.endsWith(".ts");
|
||||
const entry = new URL(source ? "./api-server-child.ts" : "./api-server-child.js", import.meta.url);
|
||||
const child = fork(entry, [provider, String(port)], {
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
execArgv: childExecArgv(source),
|
||||
});
|
||||
let ready = false;
|
||||
let expectedExit = false;
|
||||
let settled = false;
|
||||
let hasExited = false;
|
||||
let startupTimer: ReturnType<typeof setTimeout>;
|
||||
let terminateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let resolveExit!: () => void;
|
||||
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
||||
let resolveStart!: () => void;
|
||||
let rejectStart!: (error: StartupFailure) => void;
|
||||
const started = new Promise<void>((resolve, reject) => { resolveStart = resolve; rejectStart = reject; });
|
||||
const settle = (error?: SafeApiStartupError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(startupTimer);
|
||||
if (error) rejectStart(new StartupFailure(error)); else resolveStart();
|
||||
};
|
||||
const record: ManagedChild = {
|
||||
child,
|
||||
stop() {
|
||||
if (expectedExit) return exited;
|
||||
expectedExit = true;
|
||||
settle({ category: "cancelled" });
|
||||
if (children.get(provider) === record) children.delete(provider);
|
||||
stopping = Promise.all([stopping, exited]).then(() => {});
|
||||
if (hasExited) return exited;
|
||||
try {
|
||||
if (child.connected) child.send({ type: "stop" }, (error) => { if (error) child.kill("SIGTERM"); });
|
||||
else child.kill("SIGTERM");
|
||||
} catch { child.kill("SIGTERM"); }
|
||||
terminateTimer = setTimeout(() => child.kill("SIGTERM"), 1000);
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
|
||||
terminateTimer.unref(); killTimer.unref();
|
||||
return exited;
|
||||
},
|
||||
};
|
||||
children.set(provider, record);
|
||||
child.on("message", (message: unknown) => {
|
||||
if (!message || typeof message !== "object" || expectedExit || launchGeneration !== generation) return;
|
||||
const data = message as Record<string, unknown>;
|
||||
if (data.provider !== provider || data.port !== port) return;
|
||||
if (data.type === "ready") { ready = true; settle(); }
|
||||
else if (data.type === "error" && typeof data.category === "string" && ERROR_CATEGORIES.has(data.category)) {
|
||||
const code = safeApiErrorCode(data.code);
|
||||
const details: SafeApiStartupError = { category: data.category as SafeApiStartupError["category"], ...(code ? { code } : {}) };
|
||||
if (!ready) settle(details);
|
||||
else logger.error({ provider, port, ...details }, "Embedded music API reported a runtime failure");
|
||||
void record.stop();
|
||||
}
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (expectedExit) return;
|
||||
const details = getSafeApiStartupError(error);
|
||||
if (!ready) settle(details);
|
||||
else logger.error({ provider, port, ...details }, "Embedded music API child failed");
|
||||
void record.stop();
|
||||
});
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (hasExited) return;
|
||||
hasExited = true;
|
||||
clearTimeout(startupTimer); clearTimeout(terminateTimer); clearTimeout(killTimer);
|
||||
if (children.get(provider) === record) children.delete(provider);
|
||||
if (!expectedExit) {
|
||||
if (ready) logger.error({ provider, port, code, signal }, "Embedded music API exited unexpectedly");
|
||||
else settle({ category: "startup" });
|
||||
}
|
||||
resolveExit();
|
||||
};
|
||||
child.once("exit", onExit);
|
||||
// A failed fork emits close without exit.
|
||||
child.once("close", onExit);
|
||||
startupTimer = setTimeout(() => { settle({ category: "timeout" }); void record.stop(); }, STARTUP_TIMEOUT_MS);
|
||||
return started;
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
// Provider gating: with the jellyfin-only default config neither legacy
|
||||
// sidecar starts, so ports 3001/3200 are never opened.
|
||||
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
|
||||
return;
|
||||
}
|
||||
logger.info("Starting embedded music API servers...");
|
||||
|
||||
// Start NetEase Cloud Music API
|
||||
if (options.neteaseEnabled !== false) {
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
start(): Promise<void> {
|
||||
if (starting) return starting;
|
||||
const startGeneration = generation;
|
||||
const run = async () => {
|
||||
await stopping;
|
||||
if (startGeneration !== generation) return;
|
||||
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||
logger.info("NetEase/QQ providers disabled — embedded music API servers not started"); return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start QQ Music API. Older versions auto-started on import; the
|
||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||
// so we explicitly call .listen() on the imported Koa app and keep
|
||||
// the server handle for clean shutdown.
|
||||
if (options.qqEnabled === false) return;
|
||||
try {
|
||||
const portFree = await isPortFree(options.qqMusicPort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
// Pin the upstream server to the configured port before importing.
|
||||
// The package derives its default port from process.env.PORT (falling
|
||||
// back to 3200) and, in some historical versions, auto-started that
|
||||
// server as an import side effect. Aligning PORT with qqMusicApiPort
|
||||
// guarantees the sidecar can never bind a different port than the one
|
||||
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
|
||||
// issue #122, where an old build listened on 3300 while the client
|
||||
// requested 3200. Restore the previous value right after import so we
|
||||
// never leak the override into the rest of the process (e.g. the web
|
||||
// server or the NetEase sidecar, which also read PORT as a fallback).
|
||||
const prevPortEnv = process.env.PORT;
|
||||
process.env.PORT = String(options.qqMusicPort);
|
||||
let qqModule: any;
|
||||
logger.info("Starting embedded music API servers...");
|
||||
const providers: Array<{ provider: ApiProvider; port: number; enabled: boolean; name: string }> = [
|
||||
{ provider: "netease", port: options.neteasePort, enabled: options.neteaseEnabled !== false, name: "NetEase Cloud Music" },
|
||||
{ provider: "qq", port: options.qqMusicPort, enabled: options.qqEnabled !== false, name: "QQ Music" },
|
||||
];
|
||||
for (const { provider, port, enabled, name } of providers) {
|
||||
if (startGeneration !== generation) return;
|
||||
if (!enabled || children.has(provider)) continue;
|
||||
try {
|
||||
qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||
} finally {
|
||||
if (prevPortEnv === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = prevPortEnv;
|
||||
}
|
||||
// The module's export structure varies between versions:
|
||||
// 2.2.11+: default → Koa app (has .listen)
|
||||
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||
// older: module itself may be the Koa app
|
||||
const candidate = qqModule.default ?? qqModule;
|
||||
const koaApp = typeof candidate.listen === "function"
|
||||
? candidate
|
||||
: candidate.default ?? null;
|
||||
if (koaApp && typeof koaApp.listen === "function") {
|
||||
// A version that auto-started on import has already bound the
|
||||
// configured port (thanks to the PORT alignment above); reuse it
|
||||
// rather than racing a second listen that would fail EADDRINUSE.
|
||||
const stillFree = await isPortFree(options.qqMusicPort);
|
||||
if (!stillFree) {
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
|
||||
);
|
||||
} else {
|
||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||
resolve(srv)
|
||||
);
|
||||
srv.on("error", reject);
|
||||
});
|
||||
// Log the port actually bound (read from the socket) rather than
|
||||
// the requested one, so operators can spot a mismatch in the logs.
|
||||
const addr = qqMusicServer.address();
|
||||
const boundPort =
|
||||
addr && typeof addr === "object" && addr !== null
|
||||
? addr.port
|
||||
: options.qqMusicPort;
|
||||
logger.info(
|
||||
{ port: boundPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
const free = await isApiPortFree(port);
|
||||
if (startGeneration !== generation) return;
|
||||
if (!free) {
|
||||
logger.info({ port }, `${provider === "netease" ? "NetEase" : "QQ Music"} API port already in use — reusing existing instance`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
logger.warn("QQ Music API module does not expose a Koa app");
|
||||
await launch(provider, port, startGeneration);
|
||||
if (startGeneration !== generation) return;
|
||||
logger.info({ port }, `${name} API started`);
|
||||
} catch (error) {
|
||||
if (startGeneration !== generation) return;
|
||||
const details = error instanceof StartupFailure ? error.details : getSafeApiStartupError(error);
|
||||
if (details.category === "cancelled") return;
|
||||
const hint = provider === "qq" ? describeQqApiStartupError(details.category === "esm" ? { code: "ERR_REQUIRE_ESM" } : details.category === "node-engine" ? { code: "EBADENGINE" } : {}) : null;
|
||||
logger.error({ provider, port, ...details }, hint ? `QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${port} is down.` : `Failed to start ${name} API`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const hint = describeQqApiStartupError(err);
|
||||
if (hint) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const promise = run();
|
||||
starting = promise;
|
||||
void promise.finally(() => { if (starting === promise) starting = null; });
|
||||
return promise;
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
generation++;
|
||||
const pendingStart = starting;
|
||||
starting = null;
|
||||
logger.info("Stopping music API servers");
|
||||
if (neteaseServer && typeof (neteaseServer as any).close === "function") {
|
||||
(neteaseServer as any).close();
|
||||
}
|
||||
neteaseServer = null;
|
||||
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
|
||||
(qqMusicServer as any).close();
|
||||
}
|
||||
qqMusicServer = null;
|
||||
},
|
||||
|
||||
getNeteaseBaseUrl(): string {
|
||||
return neteaseBaseUrl;
|
||||
},
|
||||
|
||||
getQQMusicBaseUrl(): string {
|
||||
return qqMusicBaseUrl;
|
||||
const retiring = [...children.values()];
|
||||
children.clear();
|
||||
// A cancelled preflight still owns a temporary listening socket until
|
||||
// its callback closes it. Restart must wait for that work as well.
|
||||
stopping = Promise.all([stopping, pendingStart, ...retiring.map((record) => record.stop())]).then(() => {});
|
||||
},
|
||||
getNeteaseBaseUrl: () => `http://127.0.0.1:${options.neteasePort}`,
|
||||
getQQMusicBaseUrl: () => `http://127.0.0.1:${options.qqMusicPort}`,
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -162,3 +162,44 @@ describe("BiliBiliProvider multi-P support", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+32
-1
@@ -42,6 +42,37 @@ export interface BiliVideoPartsResult {
|
||||
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。
|
||||
@@ -353,7 +384,7 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
||||
);
|
||||
|
||||
const biliUrl = best.baseUrl ?? best.base_url;
|
||||
const biliUrl = pickStableAudioUrl(best);
|
||||
return biliUrl ? { url: biliUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+176
-1
@@ -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;
|
||||
}
|
||||
@@ -503,3 +503,117 @@ describe("music router GET /bilibili/parts", () => {
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+39
-3
@@ -1,5 +1,5 @@
|
||||
import express, { Router, type Response } from "express";
|
||||
import type { MusicProvider, Song, Album } from "../../music/provider.js";
|
||||
import type { MusicProvider, Song, Album, SearchResult } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
|
||||
@@ -198,7 +198,7 @@ export function createMusicRouter(
|
||||
// searched. Jellyfin (an opt-in source) leads the merged results when
|
||||
// enabled — a self-hosted library match is almost always the wanted one.
|
||||
const enabled = (p: string) => !config || isProviderEnabled(config, p);
|
||||
const none = { songs: [], albums: [], playlists: [] };
|
||||
const none: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
@@ -226,8 +226,14 @@ export function createMusicRouter(
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
||||
];
|
||||
// Artists come only from the sources that model them (netease/qq); other
|
||||
// providers simply contribute nothing.
|
||||
const artists = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.artists ?? [] : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.artists ?? [] : []),
|
||||
];
|
||||
|
||||
res.json({ songs, albums, playlists });
|
||||
res.json({ songs, albums, playlists, artists });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Unified search failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -297,6 +303,36 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/artist/:id", async (req, res) => {
|
||||
try {
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
if (!provider.getArtistDetail) {
|
||||
res.status(501).json({ error: "Not supported by this provider" });
|
||||
return;
|
||||
}
|
||||
// Each piece degrades independently: a source that cannot list albums (or
|
||||
// a transient upstream failure) must not take the hero or the songs down
|
||||
// with it, so every call falls back to an empty value.
|
||||
const [artist, songs, albums] = await Promise.all([
|
||||
provider.getArtistDetail(req.params.id).catch(() => null),
|
||||
provider.getArtistSongs
|
||||
? provider.getArtistSongs(req.params.id).catch(() => [] as Song[])
|
||||
: Promise.resolve([] as Song[]),
|
||||
provider.getArtistAlbums
|
||||
? provider.getArtistAlbums(req.params.id).catch(() => [] as Album[])
|
||||
: Promise.resolve([] as Album[]),
|
||||
]);
|
||||
if (!artist) {
|
||||
res.status(404).json({ error: "Artist not found" });
|
||||
return;
|
||||
}
|
||||
res.json({ artist, songs, albums });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
|
||||
@@ -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;
|
||||
@@ -176,6 +178,68 @@ describe("session router", () => {
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("session router — API key revocation", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let apiKeys: ApiKeyStore;
|
||||
let app: express.Express;
|
||||
let userId: string;
|
||||
let currentCookie: string;
|
||||
let rawKey: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
apiKeys = createApiKeyStore(botDb.db);
|
||||
const member = await users.createUser("alice", "old-password", "member");
|
||||
userId = member.id;
|
||||
currentCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||
rawKey = apiKeys.create(userId, "integration")!.rawKey;
|
||||
app = makeApp(botDb, users, sessions, apiKeys);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("successful password change revokes all owned keys and preserves only the active browser session", async () => {
|
||||
const secondKey = apiKeys.create(userId, "another-integration")!.rawKey;
|
||||
const otherSession = `${SESSION_COOKIE_NAME}=${sessions.createSession(userId).token}`;
|
||||
const otherUser = await users.createUser("bob", "other-password", "member");
|
||||
const otherUserKey = apiKeys.create(otherUser.id, "other-user-integration")!.rawKey;
|
||||
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.set("Cookie", currentCookie)
|
||||
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||
expect(changed.status).toBe(204);
|
||||
expect(apiKeys.validateAndTouch(rawKey)).toBeNull();
|
||||
expect(apiKeys.validateAndTouch(secondKey)).toBeNull();
|
||||
expect(apiKeys.listForUser(userId)).toEqual([]);
|
||||
expect(apiKeys.validateAndTouch(otherUserKey)?.userId).toBe(otherUser.id);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", otherSession)).status).toBe(401);
|
||||
}, 20_000);
|
||||
|
||||
it.each([
|
||||
{ oldPassword: "wrong-password", newPassword: "new-password", status: 401 },
|
||||
{ oldPassword: "old-password", newPassword: "short", status: 400 },
|
||||
])("failed password change ($status) leaves API keys valid", async ({ oldPassword, newPassword, status }) => {
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.set("Cookie", currentCookie)
|
||||
.send({ oldPassword, newPassword });
|
||||
expect(changed.status).toBe(status);
|
||||
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", currentCookie)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("unauthenticated password change leaves API keys valid", async () => {
|
||||
const changed = await request(app).post("/api/session/change-password")
|
||||
.send({ oldPassword: "old-password", newPassword: "new-password" });
|
||||
expect(changed.status).toBe(401);
|
||||
expect(apiKeys.validateAndTouch(rawKey)?.userId).toBe(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session router — guest mode", () => {
|
||||
let botDb: BotDatabase;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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/**"],
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
@@ -583,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>
|
||||
+302
-3
@@ -12,9 +12,35 @@
|
||||
v-model="query"
|
||||
class="search-input"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
@focus="historyOpen = true"
|
||||
@blur="historyOpen = false"
|
||||
@keyup.enter="doSearch"
|
||||
autofocus
|
||||
/>
|
||||
<button v-if="query" class="search-clear" @click="query = ''">
|
||||
<Icon icon="mdi:close-circle" />
|
||||
</button>
|
||||
<!-- Recent searches for this browser. mousedown.prevent keeps the input
|
||||
focused so blur doesn't close the list before the click lands. -->
|
||||
<div v-if="historyOpen && historyEntries.length" class="history-dropdown">
|
||||
<div class="history-dropdown-head">
|
||||
<span>搜索历史</span>
|
||||
<button class="history-clear-btn" @mousedown.prevent @click="clearAllHistory">清空</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="h in historyEntries"
|
||||
:key="`${h.platform}:${h.q}`"
|
||||
class="history-dropdown-item"
|
||||
@mousedown.prevent="applyHistory(h)"
|
||||
>
|
||||
<Icon icon="mdi:history" class="history-item-icon" />
|
||||
<span class="history-item-query">{{ h.q }}</span>
|
||||
<span class="platform-badge" :class="badgeClass(h.platform)">{{ badgeLabel(h.platform) }}</span>
|
||||
<button class="history-item-remove" @mousedown.prevent.stop="removeHistoryEntry(h)">
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -55,7 +81,7 @@
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length || allArtists.length">
|
||||
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||
(opt-in) comes first when enabled. -->
|
||||
<div class="source-bar">
|
||||
@@ -97,6 +123,31 @@
|
||||
>本地</button>
|
||||
</div>
|
||||
|
||||
<!-- Artist row: inline above the tabs, Apple Music style. Only netease/qq
|
||||
model artists, so the other sources have nothing to show here. -->
|
||||
<section v-if="filteredArtists.length" class="artist-row">
|
||||
<router-link
|
||||
v-for="ar in filteredArtists"
|
||||
:key="`${ar.platform}-${ar.id}`"
|
||||
:to="`/artist/${ar.id}?platform=${ar.platform}`"
|
||||
class="artist-card"
|
||||
>
|
||||
<img
|
||||
v-if="ar.avatarUrl"
|
||||
class="artist-avatar"
|
||||
:src="ar.avatarUrl"
|
||||
:alt="ar.name"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="artist-avatar artist-avatar-fallback">
|
||||
<Icon icon="mdi:account-music" />
|
||||
</div>
|
||||
<div class="artist-card-name">{{ ar.name }}</div>
|
||||
<div v-if="ar.songCount" class="artist-card-sub">{{ ar.songCount }} 首歌</div>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
class="tab"
|
||||
@@ -186,7 +237,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||
<div v-else-if="!searched" class="search-intro">
|
||||
<template v-if="historyEntries.length">
|
||||
<div class="history-tags-title">最近搜索</div>
|
||||
<div class="history-tags">
|
||||
<button
|
||||
v-for="h in historyEntries"
|
||||
:key="`tag-${h.platform}:${h.q}`"
|
||||
class="history-tag"
|
||||
@click="applyHistory(h)"
|
||||
>
|
||||
<Icon icon="mdi:history" />
|
||||
{{ h.q }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="search-intro-text">搜索歌曲、歌手或专辑,歌手支持专属页面</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">未找到相关结果</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -200,6 +269,14 @@ import type { Song } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||
import {
|
||||
clearStoredHistory,
|
||||
loadHistory,
|
||||
pushHistory,
|
||||
removeHistory,
|
||||
saveHistory,
|
||||
type SearchHistoryEntry,
|
||||
} from './searchHistory.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -229,11 +306,17 @@ const selectedSource = ref<SearchSource>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Artist { id: string; name: string; avatarUrl: string; platform: string; songCount?: number; albumCount?: number; }
|
||||
|
||||
const allSongs = ref<Song[]>([]);
|
||||
const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
||||
const allArtists = ref<Artist[]>([]);
|
||||
// Search history lives in localStorage (see searchHistory.ts) — loaded on mount
|
||||
// so SSR/blocked-storage environments simply start empty.
|
||||
const historyEntries = ref<SearchHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
const hasMoreMap = ref<Record<string, boolean>>({});
|
||||
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
||||
const loading = ref(false);
|
||||
@@ -257,6 +340,10 @@ const filteredPlaylists = computed(() =>
|
||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const filteredArtists = computed(() =>
|
||||
allArtists.value.filter((a) => a.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// Server-side source gate (enabledProviders). Until /providers loads, the
|
||||
@@ -383,6 +470,7 @@ async function toggleFavPlaylist(pl: { id: string; platform: string; name: strin
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
recordHistory();
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
activeTab.value = 'songs';
|
||||
@@ -393,16 +481,43 @@ async function doSearch() {
|
||||
allSongs.value = res.data.songs ?? [];
|
||||
allAlbums.value = res.data.albums ?? [];
|
||||
allPlaylists.value = res.data.playlists ?? [];
|
||||
allArtists.value = res.data.artists ?? [];
|
||||
recordInitialHasMore(allSongs.value, 'songs');
|
||||
recordInitialHasMore(allAlbums.value, 'albums');
|
||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||
} catch {
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allArtists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 搜索历史 ----
|
||||
function recordHistory() {
|
||||
historyEntries.value = pushHistory(historyEntries.value, query.value, selectedSource.value);
|
||||
saveHistory(historyEntries.value);
|
||||
}
|
||||
|
||||
/** Re-run a stored search, restoring the source it was made from. */
|
||||
function applyHistory(entry: SearchHistoryEntry) {
|
||||
historyOpen.value = false;
|
||||
query.value = entry.q;
|
||||
if (SEARCH_SOURCES.includes(entry.platform as SearchSource) && sourceEnabled(entry.platform)) {
|
||||
selectedSource.value = entry.platform as SearchSource;
|
||||
}
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function removeHistoryEntry(entry: SearchHistoryEntry) {
|
||||
historyEntries.value = removeHistory(historyEntries.value, entry.q, entry.platform);
|
||||
saveHistory(historyEntries.value);
|
||||
}
|
||||
|
||||
function clearAllHistory() {
|
||||
historyEntries.value = [];
|
||||
clearStoredHistory();
|
||||
}
|
||||
|
||||
|
||||
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
|
||||
const UPLOAD_MAX_MB = 500;
|
||||
@@ -546,6 +661,7 @@ async function loadLocalAudioSetting() {
|
||||
|
||||
onMounted(async () => {
|
||||
loadLocalAudioSetting();
|
||||
historyEntries.value = loadHistory();
|
||||
if (query.value) doSearch();
|
||||
await store.fetchProviders();
|
||||
fixupSelectedSource();
|
||||
@@ -649,6 +765,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
@@ -657,6 +774,188 @@ onMounted(async () => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.history-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% - 10px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.history-dropdown-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.history-clear-btn {
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { color: var(--color-primary); }
|
||||
}
|
||||
|
||||
.history-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--bg-card); }
|
||||
}
|
||||
|
||||
.history-item-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.history-item-query {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
|
||||
.history-dropdown-item:hover & { opacity: 1; }
|
||||
|
||||
&:hover { color: #e74c3c; }
|
||||
}
|
||||
|
||||
.search-intro {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.history-tags-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.search-intro-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.artist-row {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
flex: 0 0 auto;
|
||||
width: 104px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.artist-avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.artist-card:hover .artist-avatar {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.artist-card-name {
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: var(--fw-semi);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist-card-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 22px;
|
||||
opacity: 0.4;
|
||||
|
||||
+156
-1
@@ -48,6 +48,7 @@
|
||||
</form>
|
||||
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
|
||||
</section>
|
||||
|
||||
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||
@@ -1134,6 +1135,60 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">API 密钥</h2>
|
||||
<p class="apikey-hint">
|
||||
通过 API Key 调用本机的 REST API(请求头 <code>Authorization: Bearer</code> 或 <code>X-API-Key</code>)。
|
||||
权限与你的账户一致;明文只在生成时显示一次,之后仅能看到前缀。
|
||||
</p>
|
||||
<div class="user-list">
|
||||
<div v-for="k in apiKeyList" :key="k.id" class="user-item">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ k.name }}</div>
|
||||
<div class="apikey-meta">
|
||||
<code class="apikey-prefix">{{ k.keyPrefix }}…</code>
|
||||
<span>创建于 {{ formatDate(k.createdAt) }}</span>
|
||||
<span>{{ k.lastUsedAt ? `最后使用 ${formatDate(k.lastUsedAt)}` : '从未使用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn-sm btn-delete" title="吊销此密钥" @click="onRevokeApiKey(k)">
|
||||
<Icon icon="mdi:delete" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="apiKeyList.length === 0 && !apiKeyLoadError" class="user-empty">还没有 API Key。</div>
|
||||
<div v-if="apiKeyLoadError" class="user-error">{{ apiKeyLoadError }}</div>
|
||||
</div>
|
||||
|
||||
<form class="user-add-form" @submit.prevent="onCreateApiKey">
|
||||
<input v-model="newApiKeyName" class="input" placeholder="密钥名称(如:home-assistant)" maxlength="64" required />
|
||||
<button class="btn-sm btn-primary" type="submit" :disabled="creatingApiKey">
|
||||
{{ creatingApiKey ? '生成中…' : '生成密钥' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="apiKeyMutationError" class="user-error">{{ apiKeyMutationError }}</p>
|
||||
|
||||
<!-- Created key modal: plaintext shown exactly once -->
|
||||
<div v-if="createdKey" class="edit-modal-overlay" @click.self="createdKey = null">
|
||||
<div class="edit-modal">
|
||||
<h3 class="modal-title">密钥「{{ createdKey.key.name }}」已生成</h3>
|
||||
<p class="modal-hint">请立即复制保存——这串明文只显示这一次,关闭后只能看到前缀。</p>
|
||||
<div class="apikey-raw-row">
|
||||
<code class="apikey-raw">{{ createdKey.rawKey }}</code>
|
||||
<button class="btn-sm" @click="copyCreatedKey">
|
||||
<Icon icon="mdi:content-copy" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="keyCopied" class="apikey-copied">已复制到剪贴板</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn-sm btn-primary" @click="createdKey = null">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">
|
||||
@@ -1161,6 +1216,7 @@ import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
||||
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
@@ -2198,6 +2254,90 @@ async function onConfirmReset() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Keys management ---
|
||||
interface ApiKeyEntry { id: string; name: string; keyPrefix: string; createdAt: number; lastUsedAt: number | null }
|
||||
const apiKeyList = ref<ApiKeyEntry[]>([]);
|
||||
const apiKeyLoadError = ref('');
|
||||
const apiKeyMutationError = ref('');
|
||||
const newApiKeyName = ref('');
|
||||
const creatingApiKey = ref(false);
|
||||
const createdKey = ref<{ key: ApiKeyEntry; rawKey: string } | null>(null);
|
||||
const keyCopied = ref(false);
|
||||
|
||||
async function loadApiKeys() {
|
||||
apiKeyLoadError.value = '';
|
||||
try {
|
||||
const res = await fetch('/api/keys');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
apiKeyList.value = body.keys ?? [];
|
||||
} catch (e) {
|
||||
apiKeyLoadError.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateApiKey() {
|
||||
const name = newApiKeyName.value.trim();
|
||||
if (!name) return;
|
||||
apiKeyMutationError.value = '';
|
||||
creatingApiKey.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
createdKey.value = body;
|
||||
keyCopied.value = false;
|
||||
newApiKeyName.value = '';
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
apiKeyMutationError.value = (e as Error).message;
|
||||
} finally {
|
||||
creatingApiKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeApiKey(k: ApiKeyEntry) {
|
||||
if (!confirm(`确认吊销 API Key「${k.name}」?使用它的集成将立即失效。`)) return;
|
||||
apiKeyMutationError.value = '';
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${k.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
apiKeyMutationError.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
if (!createdKey.value) return;
|
||||
const text = createdKey.value.rawKey;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
keyCopied.value = true;
|
||||
} catch {
|
||||
// Clipboard API is unavailable on non-secure origins — fall back to a
|
||||
// temporary textarea + execCommand.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
keyCopied.value = document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-user permission editor (members only) ---
|
||||
const CAPABILITIES: { token: string; label: string }[] = [
|
||||
{ token: 'player.control', label: '播放控制' },
|
||||
@@ -2334,13 +2474,15 @@ function describeAction(e: AuditEntry): string {
|
||||
case 'user.password_changed': return `修改自己的密码`;
|
||||
case 'user.role_changed': return `变更 ${target} 的角色`;
|
||||
case 'user.permissions_changed': return `权限变更 → ${target}`;
|
||||
case 'api_key.created': return `${target} 生成 API Key`;
|
||||
case 'api_key.deleted': return `${target} 吊销 API Key`;
|
||||
default: return `${e.action} → ${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
function auditActionClass(action: string): string {
|
||||
if (action === 'user.deleted') return 'audit-action-danger';
|
||||
if (action === 'user.password_reset' || action === 'user.password_changed') return 'audit-action-warn';
|
||||
if (action === 'user.password_reset' || action === 'user.password_changed' || action === 'api_key.deleted') return 'audit-action-warn';
|
||||
return 'audit-action-ok';
|
||||
}
|
||||
|
||||
@@ -2351,6 +2493,7 @@ onMounted(() => {
|
||||
loadIdleTimeout(); // also populates the Spotify config form (same endpoint)
|
||||
loadSpotifyStatus();
|
||||
handleSpotifyRedirect();
|
||||
loadApiKeys();
|
||||
if (session.isAdmin.value) {
|
||||
loadUsers();
|
||||
loadAudit();
|
||||
@@ -3146,6 +3289,18 @@ onUnmounted(() => {
|
||||
.user-add-form .input { flex: 1; min-width: 140px; }
|
||||
.user-empty, .user-error { font-size: 12px; color: var(--text-secondary); padding: 8px 0; }
|
||||
.user-error { color: #e26a6a; }
|
||||
.apikey-hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.6; }
|
||||
.apikey-hint code { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 4px; padding: 1px 4px; }
|
||||
.apikey-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.apikey-prefix { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.apikey-raw-row { display: flex; gap: 8px; align-items: center; }
|
||||
.apikey-raw {
|
||||
flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px; word-break: break-all; padding: 8px;
|
||||
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px;
|
||||
user-select: all;
|
||||
}
|
||||
.apikey-copied { font-size: 12px; color: var(--color-primary); margin: 6px 0 0; }
|
||||
.modal-hint { color: var(--text-secondary); font-size: 12px; margin: 0 0 8px; }
|
||||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
||||
|
||||
|
||||
@@ -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