Compare commits

...
Author SHA1 Message Date
saopig1andClaude Opus 4.8 a97e72ef30 feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:42:07 +08:00
saopig1andClaude Opus 4.8 a1cc0b8574 feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:36:54 +08:00
saopig1andClaude Opus 4.8 81b8953d52 fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:26:51 +08:00
TIANYAO ZHANG f8743a904f Merge pull request #114 from ZHANGTIANYAO1/fix/qq-api-version-pin
fix(qq): 锁定 @sansenjian/qq-music-api 到 ~2.4.0 并加固启动报错
2026-06-30 23:26:28 +08:00
saopig1andClaude Opus 4.8 f6e42811a5 chore(deps): update NeteaseCloudMusicApi (pinned) + safe patch/minor bumps
Dependency audit follow-up to the QQ pin:
- NeteaseCloudMusicApi ^4.30.0 → ~4.32.0. Same rationale as @sansenjian/
  qq-music-api: it's an embedded API server loaded via dynamic import, so a loose
  `^` could drift into a breaking minor and silently kill the netease server
  (ECONNREFUSED). Tighten to ~4.32.x. Verified at runtime: server starts on 3001
  and /banner returns 200.
- Lockfile bumps within existing ^ ranges (no API-server risk, so ranges kept):
  better-sqlite3 12.8.0→12.11.1, koa 3.2.0→3.2.1, ws 8.20.0→8.21.0,
  typescript 6.0.2→6.0.3, ts3-nodejs-library 3.5.1→3.5.3, vitest 4.1.4→4.1.9,
  tsx 4.21.0→4.22.4.
- Deliberately NOT bumped (major / needs a migration): bcryptjs 2→3 (password
  hashing), @types/node 25→26, @types/supertest 6→7.

tsc clean; full suite 913 passing under vitest 4.1.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:16:25 +08:00
saopig1andClaude Opus 4.8 08260182a9 fix(qq): pin @sansenjian/qq-music-api to ~2.4.0 + guard startup failures
A loose `^2.2.10` range let npm pull a newer build of the QQ Music API. The
library went ESM in 2.3.x: an ESM-only 2.3.0/2.3.1 throws ERR_REQUIRE_ESM on
load, so the embedded server never binds port 3200 and every QQ request
(including /getQQLoginQr) fails downstream with ECONNREFUSED — the QR code never
appears and cookies look broken.

- Pin to `~2.4.0` (verified: loads via the bot's native ESM import, exposes the
  Koa app, and every endpoint qq.ts calls returns the exact shapes it parses —
  QR, recommend, lyric, play, playlist detail). Blocks the broken 2.3.0/2.3.1
  and any future 2.5 migration. NOTE: 2.4.x requires Node >=20.17 (or >=22.9).
- Add describeQqApiStartupError(): on startup failure, log an actionable error
  (ERR_REQUIRE_ESM → version-pin hint; engine mismatch → Node-upgrade hint)
  instead of a generic warning, so this is obvious from the logs next time.
- README: troubleshooting entry for "QQ 二维码不弹 / 登录失败 / cookie 无法使用"
  and the version/Node note in the dependency table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:05:47 +08:00
TIANYAO ZHANG de4343cfbb Merge pull request #113 from ZHANGTIANYAO1/docs/kugou-readme
docs: 在 README 中补充酷狗音乐音源与登录功能
2026-06-30 21:36:34 +08:00
saopig1andClaude Opus 4.8 af50d69b00 docs: document Kugou (酷狗音乐) source + login features in README
Kugou shipped in #110 but the README still listed only netease/qq/bilibili/
youtube. Add Kugou throughout:
- tagline, badge, 多平台音源, QR 登录, 歌单管理 (酷狗私人电台 !fm -k + the
  login-gated daily/recommend/user playlists)
- quick-start account login, WebUI page table (FM sources, 三→四平台 search,
  multi-platform login), architecture tree (kugou.ts), dependency table,
  milestones, and a credit to the MIT MakcRe/KuGouMusicApi reference
- command table: !play -k / !search [-k] / !artist -k / !fm -k (the flags that
  actually route to Kugou; not !playlist -k — Kugou search returns no playlists)

Also fix the in-bot `!search` usage string to include -k so it matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:36:00 +08:00
26 changed files with 1237 additions and 400 deletions

No files matched your search

+24 -11
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1> <h1 align="center">TSMusicBot</h1>
<p align="center"> <p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板 <strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
</p> </p>
<p align="center"> <p align="center">
@@ -15,6 +15,7 @@
<img src="https://img.shields.io/badge/许可证-MIT-green" /> <img src="https://img.shields.io/badge/许可证-MIT-green" />
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" /> <img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" /> <img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" /> <img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
@@ -29,17 +30,17 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源 - **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步 - **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`) - **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带 - **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化 - **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级 - **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制 - **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲 - **播放历史** — 自动记录所有播放过的歌曲
@@ -300,19 +301,19 @@ sudo systemctl start tsmusicbot
- 端口(默认 9987,自托管或非标准端口请填写实际值) - 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称 - 机器人昵称
- 可选:服务器密码、默认频道 - 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲) 3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控 4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明 ### WebUI 页面说明
| 页面 | 功能 | | 页面 | 功能 |
|------|------| |------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 | | **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 | | **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 | | **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | | **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令 ### TeamSpeak 文字命令
@@ -322,9 +323,10 @@ sudo systemctl start tsmusicbot
|------|------| |------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) | | `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| | `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 | | `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) | | `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) | | `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) | | `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
@@ -338,9 +340,10 @@ sudo systemctl start tsmusicbot
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 | | `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) | | `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) | | `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 | | `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
@@ -407,6 +410,7 @@ teamspeak-music-bot/
│ │ ├── netease.ts # 网易云音乐适配器 │ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器 │ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp) │ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储 │ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动) │ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
@@ -457,8 +461,9 @@ teamspeak-music-bot/
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 | | **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **网易云 API** | NeteaseCloudMusicApi | | **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api | | **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) | | **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 | | **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
| **界面样式** | SCSS(YesPlayMusic 设计风格) | | **界面样式** | SCSS(YesPlayMusic 设计风格) |
| **图标** | @iconify/vue | | **图标** | @iconify/vue |
@@ -573,6 +578,12 @@ A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务
**Q:端口 3200 被占用?** **Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。
**Q:播放歌曲时报 FFmpeg EACCES 错误?** **Q:播放歌曲时报 FFmpeg EACCES 错误?**
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行: A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
```bash ```bash
@@ -727,6 +738,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。 - **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。 - **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。 - **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。 - **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢 ## 致谢
@@ -747,6 +759,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 | | [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 | | [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 | | [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
## 开源许可 ## 开源许可
+342 -352
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -17,7 +17,7 @@
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@honeybbq/teamspeak-client": "^0.2.1", "@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0", "@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10", "@sansenjian/qq-music-api": "~2.4.0",
"axios": "^1.14.0", "axios": "^1.14.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
@@ -28,7 +28,7 @@
"koa": "^3.2.0", "koa": "^3.2.0",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"NeteaseCloudMusicApi": "^4.30.0", "NeteaseCloudMusicApi": "~4.32.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1", "ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
+61
View File
@@ -210,3 +210,64 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
expect(ctx.executeCommand).toHaveBeenCalledTimes(1); expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
}); });
}); });
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
it("splits a long command response into multiple sends, each under the byte cap", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
const longResponse = Array.from(
{ length: 200 },
(_, i) => `歌词 line number ${i} with some content`,
).join("\n");
ctx.executeCommand = vi.fn(async () => longResponse);
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
const calls = ctx.tsClient.sendTextMessage.mock.calls;
expect(calls.length).toBeGreaterThan(1);
for (const [chunk] of calls) {
expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900);
}
});
it("sends a short command response as a single message", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
ctx.executeCommand = vi.fn(async () => "short reply");
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply");
});
});
const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as (
this: unknown,
) => Promise<string>;
describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
it("returns ALL lyric lines, not just the first 10", async () => {
const lyricLines = Array.from({ length: 30 }, (_, i) => ({
time: i,
text: `lyric line ${i}`,
}));
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }),
};
const out = await cmdLyrics.call(ctx);
for (const l of lyricLines) {
expect(out).toContain(l.text);
}
expect(out.startsWith("Lyrics for Song:")).toBe(true);
});
it("returns 'No lyrics available' when the provider has none", async () => {
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }),
};
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
});
});
+11 -3
View File
@@ -13,6 +13,7 @@ import {
type ParsedCommand, type ParsedCommand,
} from "./commands.js"; } from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js"; import type { BotConfig } from "../data/config.js";
@@ -395,7 +396,11 @@ export class BotInstance extends EventEmitter {
try { try {
const response = await this.executeCommand(parsed, msg); const response = await this.executeCommand(parsed, msg);
if (response) { if (response) {
await this.tsClient.sendTextMessage(response); // A single long reply (e.g. full lyrics) would exceed TeamSpeak's
// per-message byte cap, so split it and send the chunks in order.
for (const chunk of splitTextIntoChunks(response)) {
await this.tsClient.sendTextMessage(chunk);
}
} }
} catch (err) { } catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error"); this.logger.error({ err, command: parsed.name }, "Command execution error");
@@ -654,7 +659,7 @@ export class BotInstance extends EventEmitter {
private async cmdSearch(cmd: ParsedCommand): Promise<string> { private async cmdSearch(cmd: ParsedCommand): Promise<string> {
const p = this.config.commandPrefix; const p = this.config.commandPrefix;
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`; if (!cmd.args) return `Usage: ${p}search <name> [-q|-k|-b|-y]`;
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 8); const result = await provider.search(cmd.args, 8);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`; if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
@@ -1064,7 +1069,10 @@ export class BotInstance extends EventEmitter {
const provider = this.getProviderFor(song.platform); const provider = this.getProviderFor(song.platform);
const lyrics = await provider.getLyrics(song.id); const lyrics = await provider.getLyrics(song.id);
if (lyrics.length === 0) return "No lyrics available"; if (lyrics.length === 0) return "No lyrics available";
const lines = lyrics.slice(0, 10).map((l) => l.text); // Include the FULL lyrics (the send path chunks them under the message
// cap). Cap only to avoid pathological spam — far above any normal song.
const MAX_LYRIC_LINES = 200;
const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text);
return `Lyrics for ${song.name}:\n${lines.join("\n")}`; return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
} }
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { splitTextIntoChunks } from "./text-chunk.js";
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
describe("splitTextIntoChunks", () => {
it("returns a single chunk for a short string", () => {
const chunks = splitTextIntoChunks("hello world", 900);
expect(chunks).toEqual(["hello world"]);
});
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 60);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(60);
}
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
expect(chunks.join("\n")).toBe(text);
});
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 150);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(150);
}
expect(chunks.join("\n")).toBe(text);
});
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
const longLine = "a".repeat(500);
const chunks = splitTextIntoChunks(longLine, 100);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(100);
}
// Content is preserved (hard-split introduces split points, not \n).
expect(chunks.join("")).toBe(longLine);
});
it("never splits a multibyte character across a hard-split boundary", () => {
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
const longLine = "歌".repeat(200);
const chunks = splitTextIntoChunks(longLine, 40);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(40);
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
expect(c.includes("�")).toBe(false);
}
expect(chunks.join("")).toBe(longLine);
});
it("preserves blank lines within a single chunk", () => {
const text = "a\n\nb";
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
*
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
* does no chunking, so a long single reply (e.g. full song lyrics) would be
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
* multibyte character is ever cut in half.
*
* Content is preserved on rejoin, modulo the split points: chunks split only on
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
* long line rejoins with `chunks.join("")`.
*
* @param text The full message text.
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
* with headroom for protocol framing/escaping).
*/
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
const chunks: string[] = [];
let current = "";
const flush = (): void => {
if (current !== "") {
chunks.push(current);
current = "";
}
};
for (const rawLine of text.split("\n")) {
const pieces =
Buffer.byteLength(rawLine, "utf8") > maxBytes
? hardSplitByBytes(rawLine, maxBytes)
: [rawLine];
for (const piece of pieces) {
const candidate = current === "" ? piece : `${current}\n${piece}`;
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
current = candidate;
} else {
// current is guaranteed non-empty here: pieces never exceed maxBytes,
// so an empty `current` always accepts the next piece above.
flush();
current = piece;
}
}
}
flush();
return chunks;
}
/**
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
* a character (iterates code points, so surrogate pairs stay intact).
*/
function hardSplitByBytes(line: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = "";
let currentBytes = 0;
for (const ch of line) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (currentBytes + chBytes > maxBytes && current !== "") {
pieces.push(current);
current = "";
currentBytes = 0;
}
current += ch;
currentBytes += chBytes;
}
if (current !== "") pieces.push(current);
return pieces;
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { describeQqApiStartupError } from "./api-server.js";
describe("describeQqApiStartupError", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
expect(hint).toMatch(/~2\.4\.0/);
expect(hint).toMatch(/~2\.2\.10/);
});
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
const hint = describeQqApiStartupError(
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
);
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
});
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
expect(hint).toMatch(/Node >=20\.17/);
expect(hint).toMatch(/~2\.2\.10/);
});
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
expect(describeQqApiStartupError(undefined)).toBeNull();
expect(describeQqApiStartupError(null)).toBeNull();
});
});
+34
View File
@@ -14,6 +14,32 @@ export interface ApiServerManager {
getQQMusicBaseUrl(): string; getQQMusicBaseUrl(): string;
} }
/**
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
* actionable operator guidance, or null when it isn't a recognised
* dependency/runtime mismatch. Exported for testing.
*
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
* that needs Node >=20.17 — either way the embedded server never binds, so
* every QQ request fails downstream with ECONNREFUSED on the API port.
*/
export function describeQqApiStartupError(err: unknown): string | null {
const e = (err ?? {}) as { code?: string; message?: string };
const code = String(e.code ?? "");
const msg = String(e.message ?? "");
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
return (
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
);
}
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
}
return null;
}
function isPortFree(port: number): Promise<boolean> { function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer(); const server = net.createServer();
@@ -100,11 +126,19 @@ export function createApiServerManager(
} }
} }
} catch (err) { } 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( logger.warn(
{ err }, { err },
"QQ Music API not available — QQ Music features may be limited" "QQ Music API not available — QQ Music features may be limited"
); );
} }
}
}, },
stop(): void { stop(): void {
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
const p = new BiliBiliProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
// Short-circuit the buvid + wbi bootstrap so search only issues the
// /search/type request we want to inspect.
(p as any).buvidInitialized = true;
(p as any).wbiMixinKey = "0".repeat(32);
(p as any).wbiKeyFetchedAt = Date.now();
(p as any).api = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls.find(
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
);
expect(call, "expected a /search/type call").toBeTruthy();
// signWbi stringifies every value.
return call![1].params as Record<string, string>;
}
it("adds page (offset/limit+1) alongside page_size", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe("2");
expect(params.page_size).toBe("20");
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe("1");
});
});
+5 -1
View File
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
return fixed; return fixed;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie(); await this.ensureBuvidCookie();
await this.ensureWbiKeys(); await this.ensureWbiKeys();
// /search/type is page-based; the web pages in limit-aligned steps so
// offset is a multiple of page_size.
const page = Math.floor(offset / limit) + 1;
const signed = this.signWbi({ const signed = this.signWbi({
search_type: "video", search_type: "video",
keyword: query, keyword: query,
page,
page_size: limit, page_size: limit,
}); });
const res = await this.api.get("/x/web-interface/wbi/search/type", { const res = await this.api.get("/x/web-interface/wbi/search/type", {
+31 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js"; import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => { describe("mapKugouSongs", () => {
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
expect(mapKugouPlaylists(undefined)).toEqual([]); expect(mapKugouPlaylists(undefined)).toEqual([]);
}); });
}); });
describe("KugouProvider.search pagination", () => {
function mockProvider() {
const p = new KugouProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
(p as any).mobileHttp = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls[0];
expect(call, "expected a mobile search call").toBeTruthy();
return call[1].params as Record<string, unknown>;
}
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe(2);
expect(params.pagesize).toBe(20);
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe(1);
});
});
+5 -2
View File
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
} }
// --- Search (verified live via the unsigned mobile endpoint) --------------- // --- Search (verified live via the unsigned mobile endpoint) ---------------
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim(); const q = query.trim();
if (!q) return { songs: [], playlists: [], albums: [] }; if (!q) return { songs: [], playlists: [], albums: [] };
try { try {
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
// steps so offset is a multiple of pagesize.
const page = Math.floor(offset / limit) + 1;
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", { const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 }, params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
}); });
const info = res.data?.data?.info as KugouRawSong[] | undefined; const info = res.data?.data?.info as KugouRawSong[] | undefined;
return { songs: mapKugouSongs(info), playlists: [], albums: [] }; return { songs: mapKugouSongs(info), playlists: [], albums: [] };
+14
View File
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
}); });
}); });
describe("LocalMusicProvider search pagination", () => {
it("slices [offset, offset+limit) instead of the first page", async () => {
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
seed(recs); // newest-first order preserved: a, b, c, d
const p = new LocalMusicProvider(dir);
const page1 = await p.search("", 2); // offset defaults to 0
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
const page2 = await p.search("", 2, 2);
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
});
});
describe("LocalMusicProvider filename handling", () => { describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => { it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir); const p = new LocalMusicProvider(dir);
+2 -2
View File
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
return song; return song;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const songs = this.records const songs = this.records
.filter((r) => existsSync(r.filePath)) .filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q)) .filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit) .slice(offset, offset + limit)
.map((r) => this.toSong(r)); .map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js"; import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => { describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => { it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined(); expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
}); });
}); });
describe("NeteaseProvider.search pagination", () => {
function mockProvider() {
const p = new NeteaseProvider("http://x");
const get = vi.fn().mockResolvedValue({
data: { result: { songs: [], playlists: [], albums: [] } },
});
(p as any).api = { get };
return { p, get };
}
/** Find the /cloudsearch call whose params.type matches. */
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
return call![1].params as Record<string, unknown>;
}
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20);
// songs (type 1): offset forwarded, limit unchanged
const songs = callByType(get, 1);
expect(songs.limit).toBe(20);
expect(songs.offset).toBe(20);
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
const playlists = callByType(get, 1000);
expect(playlists.limit).toBe(20);
expect(playlists.offset).toBe(20);
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
const albums = callByType(get, 10);
expect(albums.limit).toBe(20);
expect(albums.offset).toBe(20);
});
it("defaults offset to 0 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(callByType(get, 1).offset).toBe(0);
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
});
+8 -4
View File
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {}; return this.cookie ? { cookie: this.cookie } : {};
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams }, params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 10, limit,
offset,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams }, params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}), }),
]); ]);
+1 -1
View File
@@ -74,7 +74,7 @@ export interface AuthStatus {
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void; setQuality(quality: string): void;
getQuality(): string; getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
// and the per-instance api) share this single mock so the search test can
// inspect the outgoing params/body regardless of which client issued them.
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => { describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
expect(out[0].id).toBe(""); expect(out[0].id).toBe("");
}); });
}); });
describe("QQMusicProvider.search pagination", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
/** musicu.fcg returns one song → primary path succeeds. */
function musicuOk() {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
},
};
}
return { data: {} };
});
}
function musicuReqData() {
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
expect(call, "expected a musicu.fcg call").toBeTruthy();
return JSON.parse(call![1].params.data);
}
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(2);
expect(d.req_0.param.num_per_page).toBe(20);
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
expect(d.req_album.param.page_num).toBe(2);
expect(d.req_album.param.num_per_page).toBe(20);
expect(d.req_playlist.param.page_num).toBe(2);
expect(d.req_playlist.param.num_per_page).toBe(20);
});
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20);
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(1);
});
it("fallback client_search_cp sets p to the page cursor", async () => {
// musicu returns no songs → primary returns null → fallback runs.
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
}
// client_search_cp
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
});
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const songCall = mockGet.mock.calls.find(
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
);
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
});
+20 -10
View File
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values). // (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit); const primary = await this.searchViaMusicuFcg(query, limit, offset);
if (primary) return primary; if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album, // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy. // no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit); return this.searchViaClientSearchCp(query, limit, offset);
} }
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg. /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */ * 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg( private async searchViaMusicuFcg(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult | null> { ): Promise<SearchResult | null> {
try { try {
// num_per_page must stay >= 10 (lower values return empty). It is now
// limit-driven for ALL three lists (albums/playlists were hardcoded to
// 10). page_num is the offset cursor; the web always requests in
// limit-aligned pages so offset is a multiple of limit.
const numPerPage = Math.max(10, Math.min(limit, 50)); const numPerPage = Math.max(10, Math.min(limit, 50));
const pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({ const reqData = JSON.stringify({
req_0: { req_0: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
}, },
req_album: { req_album: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
}, },
req_playlist: { req_playlist: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
}, },
}); });
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp( private async searchViaClientSearchCp(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult> { ): Promise<SearchResult> {
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
// offset is a multiple of limit.
const page = Math.floor(offset / limit) + 1;
const songParams = { const songParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: Math.min(limit, 50), n: Math.min(limit, 50),
type: 0, type: 0,
cr: 1, cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = { const albumParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: 5, n: 5,
t: 8, t: 8,
cr: 1, cr: 1,
+9 -3
View File
@@ -115,17 +115,23 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const; readonly platform = "youtube" as const;
private quality = "bestaudio"; private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> { async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
try { try {
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
// N results. Best-effort paginate by fetching offset+limit and slicing
// locally. (offset 0 → identical to before.)
const total = offset + limit;
const raw = await runYtDlp([ const raw = await runYtDlp([
`ytsearch${limit}:${query}`, `ytsearch${total}:${query}`,
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--no-warnings", "--no-warnings",
"--quiet", "--quiet",
]); ]);
const lines = raw.trim().split("\n").filter(Boolean); const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => { const songs: Song[] = lines
.slice(offset, offset + limit)
.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry; const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry); return entryToSong(entry);
}); });
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { createMusicRouter } from "./music.js";
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
+6 -2
View File
@@ -84,7 +84,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
try { try {
const { q, platform, limit } = req.query; const { q, platform, limit, offset } = req.query;
if (!q) { if (!q) {
res.status(400).json({ error: "q (query) is required" }); res.status(400).json({ error: "q (query) is required" });
return; return;
@@ -94,9 +94,13 @@ export function createMusicRouter(
return; return;
} }
const provider = getProvider(platform as string); const provider = getProvider(platform as string);
// Server-side pagination: offset lets the web load past the first page.
// Clamp to >= 0 so a bad/negative value falls back to the first page.
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
const result = await provider.search( const result = await provider.search(
q as string, q as string,
parseInt(limit as string) || 20 parseInt(limit as string) || 20,
parsedOffset
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {
+134 -1
View File
@@ -165,6 +165,13 @@
@add="store.addSong(song)" @add="store.addSong(song)"
/> />
</section> </section>
<div v-if="showLoadMore" class="load-more-wrap">
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</template> </template>
<div v-else-if="searched" class="empty">未找到相关结果</div> <div v-else-if="searched" class="empty">未找到相关结果</div>
@@ -180,6 +187,9 @@ import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js'; import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue'; import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
const PAGE_SIZE = 20;
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
return 'netease'; return 'netease';
} }
type TabType = 'songs' | 'albums' | 'playlists';
const query = ref((route.query.q as string) || ''); const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs'); const activeTab = ref<TabType>('songs');
const selectedSource = ref<SearchSource>(loadSource()); const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; } interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
@@ -207,6 +219,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
const allSongs = ref<Song[]>([]); const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]); const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
const hasMoreMap = ref<Record<string, boolean>>({});
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
const uploading = ref(false); const uploading = ref(false);
@@ -230,6 +245,81 @@ const filteredPlaylists = computed(() =>
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local')); const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// ---- 分页 / 加载更多 ----
function pageKey(type: TabType, source: string): string {
return `${type}:${source}`;
}
const currentItems = computed(() => {
if (activeTab.value === 'albums') return filteredAlbums.value;
if (activeTab.value === 'playlists') return filteredPlaylists.value;
return filteredSongs.value;
});
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
const currentHasMore = computed(
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
);
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
function resetPagination() {
hasMoreMap.value = {};
loadingMore.value = { songs: false, albums: false, playlists: false };
}
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
function setHasMore(type: TabType, source: string, returnedCount: number) {
hasMoreMap.value = {
...hasMoreMap.value,
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
};
}
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
const counts: Record<string, number> = {};
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
const next = { ...hasMoreMap.value };
for (const [source, count] of Object.entries(counts)) {
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
}
hasMoreMap.value = next;
}
async function loadMore() {
const type = activeTab.value;
const source = selectedSource.value;
if (loadingMore.value[type]) return;
if (!currentHasMore.value) return;
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
loadingMore.value = { ...loadingMore.value, [type]: true };
try {
const res = await axios.get('/api/music/search', {
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
});
if (type === 'albums') {
const incoming = (res.data.albums ?? []) as Album[];
allAlbums.value = mergeDedup(allAlbums.value, incoming);
setHasMore(type, source, incoming.length);
} else if (type === 'playlists') {
const incoming = (res.data.playlists ?? []) as Playlist[];
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
setHasMore(type, source, incoming.length);
} else {
const incoming = (res.data.songs ?? []) as Song[];
allSongs.value = mergeDedup(allSongs.value, incoming);
setHasMore(type, source, incoming.length);
}
} catch {
// 保留 hasMore 现状,允许用户重试。
} finally {
loadingMore.value = { ...loadingMore.value, [type]: false };
}
}
// Persist source preference // Persist source preference
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ } try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
@@ -266,12 +356,16 @@ async function doSearch() {
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
activeTab.value = 'songs'; activeTab.value = 'songs';
resetPagination();
router.replace({ query: { q: query.value } }); router.replace({ query: { q: query.value } });
try { try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? []; allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? []; allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? []; allPlaylists.value = res.data.playlists ?? [];
recordInitialHasMore(allSongs.value, 'songs');
recordInitialHasMore(allAlbums.value, 'albums');
recordInitialHasMore(allPlaylists.value, 'playlists');
} catch { } catch {
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally { } finally {
@@ -601,6 +695,45 @@ onMounted(() => {
.result-section { .result-section {
margin-bottom: 32px; margin-bottom: 32px;
} }
.load-more-wrap {
display: flex;
justify-content: center;
margin: 8px 0 32px;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 28px;
border-radius: var(--radius-md);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
&:hover:not(:disabled) {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.spin {
animation: load-more-spin 0.8s linear infinite;
}
}
@keyframes load-more-spin {
to { transform: rotate(360deg); }
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js";
const item = (platform: string, id: string): Keyed & { label: string } => ({
platform,
id,
label: `${platform}:${id}`,
});
describe("searchPagination helpers (#115)", () => {
describe("itemKey", () => {
it("builds a `${platform}:${id}` key", () => {
expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42");
});
it("distinguishes same id across platforms", () => {
expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" }));
});
});
describe("mergeDedup", () => {
it("appends incoming items, existing first, order preserved", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "3"), item("netease", "4")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]);
});
it("drops incoming items already present in existing", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("drops duplicates within the incoming batch", () => {
const existing = [item("netease", "1")];
const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("treats same id on different platforms as distinct", () => {
const existing = [item("netease", "1")];
const incoming = [item("qq", "1")];
const merged = mergeDedup(existing, incoming);
expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]);
});
it("does not mutate the existing array", () => {
const existing = [item("netease", "1")];
const before = existing.slice();
mergeDedup(existing, [item("netease", "2")]);
expect(existing).toEqual(before);
});
it("handles empty incoming", () => {
const existing = [item("netease", "1")];
expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]);
});
});
describe("hasMore", () => {
it("is true when a full page came back", () => {
expect(hasMore(20, 20)).toBe(true);
});
it("is false when a short page came back", () => {
expect(hasMore(7, 20)).toBe(false);
});
it("is false when nothing came back", () => {
expect(hasMore(0, 20)).toBe(false);
});
});
describe("nextOffset", () => {
it("returns the page-aligned offset for a full first page", () => {
expect(nextOffset(20, 20)).toBe(20);
});
it("returns 0 when nothing is shown yet", () => {
expect(nextOffset(0, 20)).toBe(0);
});
it("rounds up to the next page boundary after dedup drops items", () => {
// page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40.
expect(nextOffset(35, 20)).toBe(40);
});
it("stays aligned across multiple full pages", () => {
expect(nextOffset(40, 20)).toBe(40);
expect(nextOffset(60, 20)).toBe(60);
});
});
});
+46
View File
@@ -0,0 +1,46 @@
// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab.
// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts).
/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */
export interface Keyed {
id: string;
platform: string;
}
/** Stable dedup key for a result item: `${platform}:${id}`. */
export function itemKey(item: Keyed): string {
return `${item.platform}:${item.id}`;
}
/**
* Merge `incoming` into `existing`, deduped by `${platform}:${id}`.
* Order is preserved with existing items first; incoming items already present
* (or duplicated within the incoming batch) are dropped.
*/
export function mergeDedup<T extends Keyed>(existing: T[], incoming: T[]): T[] {
const seen = new Set<string>(existing.map(itemKey));
const result = existing.slice();
for (const item of incoming) {
const key = itemKey(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/**
* Whether another page might exist: a full page (=== pageSize) means keep the
* button; a short/empty page (< pageSize) means the source is exhausted.
*/
export function hasMore(returnedCount: number, pageSize: number): boolean {
return returnedCount >= pageSize;
}
/**
* Offset for the next page request. Offsets are page-aligned, so this is simply
* the number of items already shown for that source+type.
*/
export function nextOffset(currentCountForSource: number, pageSize: number): number {
return Math.ceil(currentCountForSource / pageSize) * pageSize;
}