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
TIANYAO ZHANG e1439a5899 Merge pull request #110 from ZHANGTIANYAO1/feat/kugou-provider
feat: 增加酷狗音乐 (Kugou) 音源支持 (closes #69)
2026-06-30 21:15:12 +08:00
saopig1andClaude Opus 4.8 5ae5168b6b fix(web): keep the volume slider draggable under the per-frame progress loop (#111)
The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).

Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:05 +08:00
saopig1andClaude Opus 4.8 4cb1da29d4 fix(web): render login QR codes dark-on-light so scanners can read them
The QR images used theme-aware colours, so in the default dark theme they were
rendered light-on-dark (inverted). Many in-app scanners — notably the Kugou
music app — cannot decode an inverted QR, so the code looked fine on screen but
silently failed to scan. Force standard dark-on-light regardless of theme; the
white quiet-zone frames it cleanly in dark mode anyway. Affects all platforms'
QR login (netease/qq/bilibili/kugou).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:53 +08:00
saopig1andClaude Opus 4.8 f9caea6c79 feat(kugou): add login-gated discovery (daily/recommend/user playlists, FM) + covers
Implements the NetEase-parity login features for the Kugou provider now that
QR login works:
- getDailyRecommendSongs (每日推荐), getUserPlaylists (我的歌单), and a real
  getRecommendPlaylists (推荐歌单, was a stub) ported from the reference API.
- mapKugouSong now extracts cover art per endpoint (sizable_cover / cover /
  trans_param.union_cover, resolving the {size} template) — Kugou songs had no
  artwork before.
- Fix the playlist-song shape (combined "歌手 - 歌名" in `name`, mixsongid as the
  audio id) so opened playlists show real titles instead of 未知歌曲.
- New defensive playlist mappers keyed on global_collection_id (the only id
  getPlaylistSongs can open); dedup user playlists in case the list endpoint
  ignores pagination; firstStr() so an empty-string field can't mask a real one.

Frontend wires Kugou as a third home-discovery source (Source type, store
caches/auth, availableSources, fetchHomeData, Home FM card + source tabs,
SourceTabs label, persisted-tab whitelist). SourceTabs now highlights the
fallback-corrected source so the active tab shows when a logged-out source was
persisted (newly possible with 3 sources).

Adds kugou.test.ts coverage for the new mappers, the cover/empty-string and
playlist-shape handling, and id openability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:45 +08:00
saopig1andClaude Opus 4.8 a12c419dd2 feat(music): add Kugou (酷狗音乐) as a music source (#69)
Adds a self-contained Kugou provider (bilibili-style: direct API calls, no
embedded API server, no new npm dependency) plus full backend + WebUI wiring.

Provider (src/music/kugou.ts): search, song-url (with device registration),
lyrics (KRC decode), song detail, playlist, album, personal FM, QR login +
cookie persistence, and quality. Request signing / crypto / KRC decoding are
ported from the MIT-licensed MakcRe/KuGouMusicApi using Node's built-in
crypto and zlib (no third-party crypto packages).

Wiring: the "kugou" platform is threaded through the provider contract, queue,
play-history, bot instance/manager dispatch (getProviderFor + the -k command
flag), index/server composition, the music/player/auth routers (unified
/search/all, /quality, the platform coercions, QR login), the cookie store,
and the WebUI (search source tab + badge, SongCard badge, brand token, and a
Kugou QR/cookie login card in Settings).

Verified live during development: search, lyrics, and album playback resolve
correctly. NOT verifiable in CI (Kugou anti-bot blocks the build host's IP):
play-URL resolution, QR login, and VIP audio — these are built faithfully to
the reference and need end-to-end testing on a non-flagged IP / a Kugou
account. See the header comment in kugou.ts.

Includes src/music/kugou.test.ts (mappers + KRC→LRC). An adversarial review
pass fixed: pagination truncating on filtered counts, an ms/seconds duration
heuristic, dfid soft-fail caching, the /v5/url random-dfid fallback, the FM
body identity, and an unguarded nickname decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:55:41 +08:00
46 changed files with 2884 additions and 467 deletions

No files matched your search

+24 -11
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1>
<p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
</p>
<p align="center">
@@ -15,6 +15,7 @@
<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/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/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
@@ -29,17 +30,17 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
@@ -300,19 +301,19 @@ sudo systemctl start tsmusicbot
- 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -322,9 +323,10 @@ sudo systemctl start tsmusicbot
|------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
@@ -338,9 +340,10 @@ sudo systemctl start tsmusicbot
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -407,6 +410,7 @@ teamspeak-music-bot/
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
@@ -457,8 +461,9 @@ teamspeak-music-bot/
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **网易云 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 登录) |
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
| **界面样式** | SCSS(YesPlayMusic 设计风格) |
| **图标** | @iconify/vue |
@@ -573,6 +578,12 @@ A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务
**Q:端口 3200 被占用?**
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 错误?**
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
```bash
@@ -727,6 +738,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢
@@ -747,6 +759,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| [@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 完整客户端协议实现 |
| [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",
"@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"@sansenjian/qq-music-api": "~2.4.0",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -28,7 +28,7 @@
"koa": "^3.2.0",
"koa-bodyparser": "^4.4.1",
"koa-static": "^5.0.0",
"NeteaseCloudMusicApi": "^4.30.0",
"NeteaseCloudMusicApi": "~4.32.0",
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
+61
View File
@@ -210,3 +210,64 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
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");
});
});
+17 -4
View File
@@ -13,6 +13,7 @@ import {
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
@@ -36,6 +37,7 @@ export interface BotInstanceOptions {
bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -69,6 +71,7 @@ export class BotInstance extends EventEmitter {
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -98,6 +101,7 @@ export class BotInstance extends EventEmitter {
this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -392,7 +396,11 @@ export class BotInstance extends EventEmitter {
try {
const response = await this.executeCommand(parsed, msg);
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) {
this.logger.error({ err, command: parsed.name }, "Command execution error");
@@ -524,10 +532,11 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
@@ -540,6 +549,7 @@ export class BotInstance extends EventEmitter {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider;
if (flags.has("k")) return this.kugouProvider;
return this.neteaseProvider;
}
@@ -649,7 +659,7 @@ export class BotInstance extends EventEmitter {
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
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 result = await provider.search(cmd.args, 8);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
@@ -1059,7 +1069,10 @@ export class BotInstance extends EventEmitter {
const provider = this.getProviderFor(song.platform);
const lyrics = await provider.getLyrics(song.id);
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")}`;
}
+7 -1
View File
@@ -75,6 +75,7 @@ export class BotManager extends EventEmitter {
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -92,7 +93,8 @@ export class BotManager extends EventEmitter {
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider
localProvider?: MusicProvider,
kugouProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
@@ -137,6 +140,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -276,6 +280,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -329,6 +334,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database,
config: this.config,
logger: this.logger,
+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;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
coverUrl: string;
}
+7 -1
View File
@@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
@@ -57,6 +58,7 @@ async function main() {
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider();
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -66,6 +68,8 @@ async function main() {
if (qqCookie) qqProvider.setCookie(qqCookie);
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const kugouCookie = cookieStore.load("kugou");
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
const permissions = createPermissionStore(db.db);
@@ -79,7 +83,8 @@ async function main() {
avatarStore,
permissions,
CONFIG_PATH,
localProvider
localProvider,
kugouProvider
);
await botManager.loadSavedBots();
@@ -90,6 +95,7 @@ async function main() {
qqProvider,
bilibiliProvider,
localProvider,
kugouProvider,
database: db,
avatarStore,
config,
+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();
});
});
+38 -4
View File
@@ -14,6 +14,32 @@ 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";
}
return null;
}
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
@@ -100,10 +126,18 @@ export function createApiServerManager(
}
}
} catch (err) {
logger.warn(
{ err },
"QQ Music API not available — QQ Music features may be limited"
);
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"
);
}
}
},
+4 -4
View File
@@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path";
export interface CookieStore {
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
load(platform: "netease" | "qq" | "bilibili"): string;
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
}
export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
}
return {
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync(
filePath,
@@ -21,7 +21,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
);
},
load(platform: "netease" | "qq" | "bilibili"): string {
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return "";
try {
+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;
}
async search(query: string, limit = 20): Promise<SearchResult> {
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie();
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({
search_type: "video",
keyword: query,
page,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => {
it("maps the mobile-search song shape to a Song with platform 'kugou'", () => {
// Shape captured live from mobilecdn.kugou.com/api/v3/search/song.
const raw = {
hash: "B3A52A7A958BF0AED0EBFBA2E9A818B7",
album_audio_id: 32100650,
album_id: "966846",
songname: "晴天",
singername: "周杰伦",
duration: 269,
};
const [song] = mapKugouSongs([raw]);
expect(song.platform).toBe("kugou");
expect(song.name).toBe("晴天");
expect(song.artist).toBe("周杰伦");
expect(song.duration).toBe(269);
// The id must round-trip hash + album_audio_id + album_id so getSongUrl
// can resolve a stream from a search result.
expect(song.id).toBe("b3a52a7a958bf0aed0ebfba2e9a818b7|32100650|966846");
});
it("treats nested audio_info durations as milliseconds and flat search durations as seconds", () => {
// List endpoints: ms under audio_info.
expect(mapKugouSong({ audio_info: { hash: "abc", duration: 215000 } }).duration).toBe(215);
// Search endpoint: a long-form track's flat `duration` stays seconds (no /1000).
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 10800 }).duration).toBe(10800);
});
it("falls back to splitting '歌手 - 歌名' from the filename", () => {
const song = mapKugouSong({ FileHash: "deadbeef", filename: "周杰伦 - 稻香", Duration: 200 });
expect(song.artist).toBe("周杰伦");
expect(song.name).toBe("稻香");
});
it("uses PascalCase fields from the gateway shape and skips entries with no hash", () => {
const songs = mapKugouSongs([
{ FileHash: "aa", SongName: "n", SingerName: "s", MixSongID: 1, AlbumID: 2 },
{ songname: "no hash" } as any,
]);
expect(songs).toHaveLength(1);
expect(songs[0].id).toBe("aa|1|2");
});
it("returns [] for non-array input", () => {
expect(mapKugouSongs(undefined)).toEqual([]);
});
it("maps the NESTED album/playlist track shape (base + audio_info)", () => {
// Shape captured live from /album/songs (叶惠美).
const raw = {
base: { album_id: 966846, album_audio_id: 32100648, audio_name: "以父之名", author_name: "周杰伦" },
audio_info: { hash: "DBC0207490EB51153EF933EF5A7E98E4", duration: 342047 },
};
const [song] = mapKugouSongs([raw]);
expect(song.name).toBe("以父之名");
expect(song.artist).toBe("周杰伦");
expect(song.duration).toBe(342); // ms → s
expect(song.id).toBe("dbc0207490eb51153ef933ef5a7e98e4|32100648|966846");
});
});
describe("krcToLrc", () => {
it("converts KRC [startMs,durMs] line timestamps + strips word timings into parseable LRC", () => {
// Shape captured live from lyrics.kugou.com (周杰伦 - 晴天).
const krc = [
"[ti:晴天]",
"[ar:周杰伦]",
"[0,2250]<0,160,0>晴<160,160,0>天",
"[63000,1800]<0,200,0>故<200,200,0>事",
].join("\n");
const lrc = krcToLrc(krc);
expect(lrc).toContain("[00:00.00]晴天");
expect(lrc).toContain("[01:03.00]故事");
expect(lrc).not.toMatch(/<\d+,\d+,\d+>/); // word timings stripped
// And the shared LRC parser must now actually produce timed lines.
const lines = parseLyrics(lrc);
expect(lines.length).toBe(2);
expect(lines[0]).toEqual({ time: 0, text: "晴天" });
expect(lines[1].time).toBe(63);
expect(lines[1].text).toBe("故事");
});
});
describe("mapKugouAlbums", () => {
it("maps album shape and normalises {size} covers to https", () => {
const [album] = mapKugouAlbums([
{ album_id: 966846, album_name: "叶惠美", singername: "周杰伦", sizable_cover: "http://imge.kugou.com/x/{size}/abc.jpg", songcount: 11 },
]);
expect(album.platform).toBe("kugou");
expect(album.id).toBe("966846");
expect(album.name).toBe("叶惠美");
expect(album.coverUrl).toBe("https://imge.kugou.com/x/240/abc.jpg");
expect(album.songCount).toBe(11);
});
});
describe("mapKugouSong — playlist (get_other_list_file) shape", () => {
it("splits the combined `name` and uses `mixsongid` as the audio id", () => {
// Shape captured live from /pubsongs/v2/get_other_list_file_nofilt (我喜欢).
// The combined "歌手 - 歌名" is in `name`; there is no separate songname.
const raw = {
name: "KOKIA - ありがとう… (谢谢…)",
hash: "E9A08A98614DD992F11A68A5E5F1C79F",
album_id: "1491689",
mixsongid: 37533796,
timelen: 248528, // ms
cover: "http://imge.kugou.com/stdmusic/{size}/20210113/x.jpg",
};
const song = mapKugouSong(raw);
expect(song.artist).toBe("KOKIA");
expect(song.name).toBe("ありがとう… (谢谢…)");
// hash lowercased | mixsongid | album_id — so getSongUrl can resolve it.
expect(song.id).toBe("e9a08a98614dd992f11a68a5e5f1c79f|37533796|1491689");
expect(song.duration).toBe(249); // timelen ms → s
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/20210113/x.jpg");
});
});
describe("mapKugouSong — cover art per endpoint", () => {
it("reads `sizable_cover` (daily/FM shape) and resolves {size}→240, http→https", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
sizable_cover: "http://imge.kugou.com/stdmusic/{size}/y.jpg",
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/y.jpg");
});
it("falls back to `trans_param.union_cover` (search shape, no top-level cover)", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
});
it("returns an empty coverUrl when no cover field is present", () => {
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 200 }).coverUrl).toBe("");
});
it("skips an empty-string cover field (Kugou returns '') and uses the next non-empty source", () => {
const song = mapKugouSong({
hash: "abc",
songname: "x",
duration: 200,
sizable_cover: "", // present but empty — must NOT mask the real cover below
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
});
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
});
});
describe("mapKugouPlaylists", () => {
it("maps the user-playlist shape (/v7/get_all_list info), keying id on global_collection_id", () => {
// Shape captured live from /v7/get_all_list (我喜欢).
const [pl] = mapKugouPlaylists([
{ name: "我喜欢", count: 7, global_collection_id: "collection_3_2526507197_2_0", listid: 2, type: 0 },
]);
expect(pl.platform).toBe("kugou");
expect(pl.name).toBe("我喜欢");
expect(pl.songCount).toBe(7);
// Must be the global_collection_id — the only id getPlaylistSongs can open.
expect(pl.id).toBe("collection_3_2526507197_2_0");
});
it("maps the recommend shape (/v2/special_recommend), preferring global_collection_id over specialid", () => {
// Shape captured live from /v2/special_recommend special_list.
const pl = mapKugouPlaylist({
specialname: "米津玄师:蒙着眼睛也能炸翻全场",
specialid: 1154672,
global_collection_id: "collection_1_1029965246_1154672_0",
pic: "http://imge.kugou.com/specialimg/{size}/a.jpg",
percount: 0,
});
expect(pl.id).toBe("collection_1_1029965246_1154672_0");
expect(pl.name).toBe("米津玄师:蒙着眼睛也能炸翻全场");
expect(pl.coverUrl).toBe("https://imge.kugou.com/specialimg/240/a.jpg");
});
it("drops entries whose only id is a non-openable specialid/listid (not a global_collection_id)", () => {
// getPlaylistSongs can only open a global_collection_id, so a numeric
// specialid/listid would be a dead id — such entries must be dropped.
expect(mapKugouPlaylists([{ specialname: "x", specialid: 1154672, listid: 9 }])).toHaveLength(0);
expect(mapKugouPlaylists([{ name: "no id" }])).toHaveLength(0);
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);
});
});
+1031
View File
File diff suppressed because it is too large. Load diff
+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", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
+2 -2
View File
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
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 songs = this.records
.filter((r) => existsSync(r.filePath))
.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));
return { songs, playlists: [], albums: [] };
}
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
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 } : {};
}
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([
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", {
params: {
keywords: query,
type: 1000,
limit: 10,
limit,
offset,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}),
]);
+5 -5
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
}
export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
}
export interface LyricLine {
@@ -72,9 +72,9 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
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>;
setQuality(quality: string): void;
getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
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 +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (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;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// 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.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
limit: number,
offset = 0
): Promise<SearchResult | null> {
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 pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
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: {
module: "music.search.SearchCgiService",
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: {
module: "music.search.SearchCgiService",
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", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
limit: number,
offset = 0
): 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 = {
w: query,
format: "json",
p: 1,
p: page,
n: Math.min(limit, 50),
type: 0,
cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = {
w: query,
format: "json",
p: 1,
p: page,
n: 5,
t: 8,
cr: 1,
+12 -6
View File
@@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const;
private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> {
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
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([
`ytsearch${limit}:${query}`,
`ytsearch${total}:${query}`,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
]);
const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
const songs: Song[] = lines
.slice(offset, offset + limit)
.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
return { songs, playlists: [], albums: [] };
} catch {
return { songs: [], playlists: [], albums: [] };
+5 -1
View File
@@ -11,7 +11,8 @@ export function createAuthRouter(
qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger,
cookieStore?: CookieStore
cookieStore?: CookieStore,
kugouProvider?: MusicProvider
): Router {
const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can
@@ -21,6 +22,7 @@ export function createAuthRouter(
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -65,6 +67,7 @@ export function createAuthRouter(
if (status === "confirmed") {
const cookie = provider.getCookie();
const plat = (platform as string) === "bilibili" ? "bilibili" as const
: (platform as string) === "kugou" ? "kugou" as const
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
if (cookie && cookieStore) {
cookieStore.save(plat, cookie);
@@ -137,6 +140,7 @@ export function createAuthRouter(
const provider = getProvider(platform);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
: platform === "kugou" ? "kugou" as const
: platform === "qq" ? "qq" as const : "netease" as const;
if (cookieStore) {
cookieStore.save(plat, cookie);
+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);
});
});
+16 -4
View File
@@ -13,7 +13,8 @@ export function createMusicRouter(
bilibiliProvider: MusicProvider,
logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig
config?: BotConfig,
kugouProvider?: MusicProvider
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -26,6 +27,7 @@ export function createMusicRouter(
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -82,7 +84,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => {
try {
const { q, platform, limit } = req.query;
const { q, platform, limit, offset } = req.query;
if (!q) {
res.status(400).json({ error: "q (query) is required" });
return;
@@ -92,9 +94,13 @@ export function createMusicRouter(
return;
}
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(
q as string,
parseInt(limit as string) || 20
parseInt(limit as string) || 20,
parsedOffset
);
res.json(result);
} catch (err) {
@@ -111,11 +117,12 @@ export function createMusicRouter(
return;
}
const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
]);
const songs = [
@@ -123,6 +130,7 @@ export function createMusicRouter(
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
@@ -286,6 +294,7 @@ export function createMusicRouter(
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
});
});
@@ -305,6 +314,9 @@ export function createMusicRouter(
if (!platform || platform === "bilibili") {
bilibiliProvider.setQuality(quality);
}
if ((!platform || platform === "kugou") && kugouProvider) {
kugouProvider.setQuality(quality);
}
logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality });
});
+5 -4
View File
@@ -39,6 +39,7 @@ export function createPlayerRouter(
if (platform === "bilibili") return "-b";
if (platform === "qq") return "-q";
if (platform === "youtube") return "-y";
if (platform === "kugou") return "-k";
return "";
};
@@ -115,7 +116,7 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform
: "netease"
);
@@ -290,7 +291,7 @@ export function createPlayerRouter(
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform
: "netease"
);
@@ -383,7 +384,7 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform
: "netease"
);
@@ -614,7 +615,7 @@ export function createPlayerRouter(
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform
: "netease"
);
+3 -2
View File
@@ -39,6 +39,7 @@ export interface WebServerOptions {
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
localProvider: MusicProvider;
kugouProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
@@ -124,7 +125,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
@@ -132,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
));
app.use(
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider)
);
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
+19 -9
View File
@@ -44,11 +44,15 @@
type="range"
min="0"
max="100"
:value="mobileVolume"
:value="mobileVolumeDisplay"
class="m-volume-slider"
@input="onMobileVolumeChange"
@input="onMobileVolumeInput"
@change="onMobileVolumeCommit"
@pointerup="onMobileVolumeRelease"
@pointercancel="onMobileVolumeRelease"
@blur="onMobileVolumeRelease"
/>
<span class="m-volume-value">{{ mobileVolume }}</span>
<span class="m-volume-value">{{ mobileVolumeDisplay }}</span>
</div>
</div>
@@ -79,6 +83,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js';
import { useDecoupledSlider } from './composables/useDecoupledSlider.js';
import { useWebSocket } from './composables/useWebSocket.js';
import { useSession } from './composables/useSession.js';
import Navbar from './components/Navbar.vue';
@@ -99,7 +104,17 @@ const route = useRoute();
const router = useRouter();
const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong);
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
// Volume slider decoupled from the 60fps updateMobileProgress() rAF re-render
// so it isn't reset mid-drag (#111 — same root cause as the desktop player).
const {
display: mobileVolumeDisplay,
onInput: onMobileVolumeInput,
onChange: onMobileVolumeCommit,
onRelease: onMobileVolumeRelease,
} = useDecoupledSlider(
() => playerStore.activeBot?.volume,
(v) => playerStore.setVolume(v)
);
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
const mobileModeIcons: Record<string, string> = {
@@ -126,11 +141,6 @@ function updateMobileProgress() {
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
function onMobileVolumeChange(e: Event) {
const volume = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(volume);
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
+17 -5
View File
@@ -65,8 +65,12 @@
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
:value="volumeDisplay"
@input="onVolumeInput"
@change="onVolumeChange"
@pointerup="onVolumeRelease"
@pointercancel="onVolumeRelease"
@blur="onVolumeRelease"
class="volume-slider"
/>
</template>
@@ -87,6 +91,7 @@ import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import { useDecoupledSlider } from '../composables/useDecoupledSlider.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -185,10 +190,17 @@ function togglePlay() {
}
}
function onVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
store.setVolume(parseInt(target.value));
}
// Volume slider is decoupled from the per-frame rAF re-render so dragging the
// thumb isn't reset every frame (#111). See useDecoupledSlider.
const {
display: volumeDisplay,
onInput: onVolumeInput,
onChange: onVolumeChange,
onRelease: onVolumeRelease,
} = useDecoupledSlider(
() => activeBot.value?.volume,
(v) => store.setVolume(v)
);
const modeOrder = ['seq', 'loop', 'random', 'rloop'] as const;
const modeIcons: Record<string, string> = {
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
@@ -140,6 +140,11 @@ function formatDuration(seconds: number): string {
color: var(--color-primary);
}
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+1
View File
@@ -24,6 +24,7 @@ import type { Source } from '../stores/player.js';
const LABELS: Record<Source, string> = {
netease: '网易云',
qq: 'QQ',
kugou: '酷狗',
};
defineProps<{
@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
import { ref, nextTick } from "vue";
import { useDecoupledSlider } from "./useDecoupledSlider.js";
/** Minimal stand-in for an <input type="range"> change/input Event. */
function ev(value: number): Event {
return { target: { value: String(value) } } as unknown as Event;
}
describe("useDecoupledSlider (#111)", () => {
it("initialises display from the source, falling back when undefined", () => {
const src = ref<number | undefined>(40);
const { display } = useDecoupledSlider(() => src.value, () => {});
expect(display.value).toBe(40);
const empty = useDecoupledSlider(() => undefined, () => {}, 75);
expect(empty.display.value).toBe(75);
});
it("reflects external source changes into the display when not dragging", async () => {
const src = ref<number | undefined>(50);
const { display } = useDecoupledSlider(() => src.value, () => {});
src.value = 80;
await nextTick();
expect(display.value).toBe(80);
});
it("tracks @input locally without committing", () => {
const commit = vi.fn();
const { display, onInput } = useDecoupledSlider(() => 50, commit);
onInput(ev(63));
expect(display.value).toBe(63);
expect(commit).not.toHaveBeenCalled();
});
// THE REGRESSION: this is exactly what the 60fps rAF re-render did — push the
// (stale) source value back into the binding mid-drag. The guard must ignore
// it so the thumb stays where the user dragged it.
it("ignores external source changes WHILE dragging (no snap-back)", async () => {
const src = ref<number | undefined>(50);
const { display, onInput } = useDecoupledSlider(() => src.value, () => {});
onInput(ev(70)); // user starts dragging → display 70
expect(display.value).toBe(70);
// Simulate the per-frame re-render re-evaluating the (still-stale) source.
src.value = 50;
await nextTick();
expect(display.value).toBe(70); // stayed put — did NOT snap back to 50
});
// Corner case: a range input skips `change` when released back at its start
// value. onRelease (pointerup/pointercancel/blur) must still end the drag so
// the slider doesn't freeze against later external updates.
it("clears dragging on release even when @change never fires", async () => {
const src = ref<number | undefined>(50);
const { display, onInput, onRelease } = useDecoupledSlider(() => src.value, () => {});
onInput(ev(70)); // drag begins
onInput(ev(50)); // ...dragged back to the start value
onRelease(); // released — browser emits NO change event here
src.value = 30; // a later external update
await nextTick();
expect(display.value).toBe(30); // slider resumed following the source
});
it("commits on @change and resumes following the source afterwards", async () => {
const commit = vi.fn();
const src = ref<number | undefined>(50);
const { display, onInput, onChange } = useDecoupledSlider(() => src.value, commit);
onInput(ev(70));
onChange(ev(70)); // release
expect(commit).toHaveBeenCalledTimes(1);
expect(commit).toHaveBeenCalledWith(70);
expect(display.value).toBe(70);
// After release, external changes flow through again.
src.value = 35;
await nextTick();
expect(display.value).toBe(35);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { ref, watch, type Ref } from 'vue';
/**
* A slider whose displayed value is decoupled from its reactive source.
*
* Why this exists (#111): the player components run a 60fps requestAnimationFrame
* loop (progress clock, #107) that re-renders the whole component every ~16ms.
* Binding a range `<input :value>` straight to a reactive source (the store
* volume) let Vue re-apply `el.value = source` on every one of those re-renders.
* Mid-drag the source is still the *old* value, so the native drag position kept
* getting snapped back — the thumb was effectively un-draggable on desktop and
* janky on mobile.
*
* The fix is to bind `:value` to a LOCAL ref that:
* - tracks the native drag synchronously via `@input` (so the bound value always
* equals the element's value → Vue never resets it), and
* - is committed to the real source only on `@change` (release).
* External source changes (bot switch, another client) still flow into the
* display — except while the user is actively dragging, where they must be
* ignored or they'd fight the drag.
*
* @param source getter for the authoritative value (e.g. () => bot?.volume)
* @param commit called with the final value on release (e.g. store.setVolume)
* @param fallback value to show when the source is undefined (default 75)
*/
export function useDecoupledSlider(
source: () => number | undefined,
commit: (value: number) => void,
fallback = 75
): {
display: Ref<number>;
dragging: Ref<boolean>;
onInput: (e: Event) => void;
onChange: (e: Event) => void;
onRelease: () => void;
} {
const display = ref<number>(source() ?? fallback);
const dragging = ref(false);
watch(source, (v) => {
// Reflect external/store changes — but never while dragging, or the
// per-frame re-render would yank the thumb away from the user's finger.
if (!dragging.value && typeof v === 'number') display.value = v;
});
function onInput(e: Event): void {
dragging.value = true;
display.value = Number((e.target as HTMLInputElement).value);
}
function onChange(e: Event): void {
dragging.value = false;
const v = Number((e.target as HTMLInputElement).value);
display.value = v;
commit(v);
}
function onRelease(): void {
// Safety net for pointerup / pointercancel / blur: a range input does NOT
// emit `change` if the value is released back at its starting point, which
// would otherwise leave `dragging` stuck true and freeze the slider against
// later external updates. Clearing here is idempotent with onChange.
dragging.value = false;
}
return { display, dragging, onInput, onChange, onRelease };
}
+39 -10
View File
@@ -10,10 +10,10 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou';
}
export type Source = 'netease' | 'qq';
export type Source = 'netease' | 'qq' | 'kugou';
export interface BotStatus {
id: string;
@@ -100,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
theme: 'dark' as 'dark' | 'light',
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
authStatus: { netease: false, qq: false, kugou: false },
lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
@@ -157,6 +157,7 @@ export const usePlayerStore = defineStore('player', {
const s: Source[] = [];
if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou');
return s;
},
},
@@ -571,18 +572,23 @@ export const usePlayerStore = defineStore('player', {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
const [neAuthRes, qqAuthRes, kugouAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
]);
const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
};
const authChanged =
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq ||
newAuth.kugou !== this.authStatus.kugou;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou;
// Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions.
@@ -621,15 +627,30 @@ export const usePlayerStore = defineStore('player', {
Promise.resolve(emptyPlaylists),
];
// 4. Kugou data: every section (incl. recommend playlists) needs login,
// so gate all three on auth like QQ.
const kugouPromises = this.authStatus.kugou
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'kugou' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
const results = await Promise.allSettled([
...neteasePromises,
...qqPromises,
...kugouPromises,
biliPromise,
]);
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
this.recommendPlaylists.netease =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
@@ -643,6 +664,12 @@ export const usePlayerStore = defineStore('player', {
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
this.userPlaylists.qq =
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.kugou =
kgRecPL.status === 'fulfilled' ? (kgRecPL.value.data.playlists ?? []) : [];
this.dailySongs.kugou =
kgDaily.status === 'fulfilled' ? (kgDaily.value.data.songs ?? []) : [];
this.userPlaylists.kugou =
kgUserPL.status === 'fulfilled' ? (kgUserPL.value.data.playlists ?? []) : [];
// bilibili popular: keep previous value on failure (it's an anonymous endpoint
// unrelated to user auth state, and stale popular results are harmless)
if (bili.status === 'fulfilled') {
@@ -654,7 +681,9 @@ export const usePlayerStore = defineStore('player', {
// cached for 5 minutes, otherwise the user has to hard-reload to
// recover when connectivity returns.
const authOk =
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled';
neAuthRes.status === 'fulfilled' ||
qqAuthRes.status === 'fulfilled' ||
kugouAuthRes.status === 'fulfilled';
if (authOk) {
this.lastFetchTime = Date.now();
}
+1 -1
View File
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
const v = all[key];
return v === 'netease' || v === 'qq' ? v : fallback;
return v === 'netease' || v === 'qq' || v === 'kugou' ? v : fallback;
}
export function saveTabSource(key: TabKey, value: Source): void {
+2
View File
@@ -80,6 +80,8 @@
--brand-bilibili-15: rgba(0, 161, 214, 0.15);
--brand-youtube: #ff0000;
--brand-youtube-12: rgba(255, 0, 0, 0.12);
--brand-kugou: #2ca2f9;
--brand-kugou-12: rgba(44, 162, 249, 0.12);
}
// Dark theme (default)
+18 -3
View File
@@ -41,13 +41,23 @@
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
<div class="fm-icon-wrapper kugou">
<Icon icon="mdi:radio-tower" class="fm-icon" />
</div>
<div class="fm-info">
<div class="fm-title">酷狗私人电台</div>
<div class="fm-desc">个性化推荐歌曲流</div>
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
</section>
<!-- 每日推荐 -->
<section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title">
每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
<SourceTabs :model-value="dailySourceSafe" @update:model-value="dailySource = $event" :sources="dailyAvailable" />
</h2>
<div class="daily-grid">
<div
@@ -67,7 +77,7 @@
<section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
<SourceTabs :model-value="recommendSourceSafe" @update:model-value="recommendSource = $event" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
@@ -108,7 +118,7 @@
<h2 class="section-title">
我的歌单
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
@@ -173,6 +183,7 @@ const userPlaylistsExpanded = ref(false);
const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq');
if (store.authStatus.kugou) s.push('kugou');
return s;
});
const dailyAvailable = computed<Source[]>(() => store.availableSources);
@@ -358,6 +369,10 @@ onMounted(() => {
&.qq {
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
}
&.kugou {
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
}
}
.fm-icon {
+1 -1
View File
@@ -28,7 +28,7 @@
<h2 class="section-title">
我的歌单
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
+151 -6
View File
@@ -72,6 +72,11 @@
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'kugou' }"
@click="selectedSource = 'kugou'"
>酷狗</button>
<button
v-if="hasLocalSongs"
class="source-btn"
@@ -89,7 +94,7 @@
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
@@ -97,7 +102,7 @@
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
@@ -160,6 +165,13 @@
@add="store.addSong(song)"
/>
</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>
<div v-else-if="searched" class="empty">未找到相关结果</div>
@@ -175,6 +187,9 @@ import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue';
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
const PAGE_SIZE = 20;
const store = usePlayerStore();
const route = useRoute();
@@ -182,18 +197,20 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
function loadSource(): SearchSource {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
type TabType = 'songs' | 'albums' | 'playlists';
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());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
@@ -202,6 +219,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
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 searched = ref(false);
const uploading = ref(false);
@@ -225,6 +245,81 @@ const filteredPlaylists = computed(() =>
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
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
@@ -232,7 +327,7 @@ watch(selectedSource, (src) => {
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => {
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
@@ -261,12 +356,16 @@ async function doSearch() {
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
resetPagination();
router.replace({ query: { q: query.value } });
try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
recordInitialHasMore(allSongs.value, 'songs');
recordInitialHasMore(allAlbums.value, 'albums');
recordInitialHasMore(allPlaylists.value, 'playlists');
} catch {
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally {
@@ -351,6 +450,7 @@ function badgeLabel(platform: string): string {
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地';
if (platform === 'kugou') return '酷狗';
return '网易云';
}
@@ -359,6 +459,7 @@ function badgeClass(platform: string): string {
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local';
if (platform === 'kugou') return 'badge-kugou';
return 'badge-netease';
}
@@ -594,6 +695,45 @@ onMounted(() => {
.result-section {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
@@ -645,6 +785,11 @@ onMounted(() => {
color: var(--color-primary);
}
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.fav-badge {
position: absolute;
top: 8px;
+95 -6
View File
@@ -377,6 +377,76 @@
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
</div>
</div>
<!-- Kugou -->
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
<div class="account-info">
<div class="account-name">酷狗音乐</div>
<div class="account-status" :class="{ logged: kugouAuth.loggedIn }">
{{ kugouAuth.loggedIn ? `已登录: ${kugouAuth.nickname}` : '未登录' }}
</div>
</div>
</div>
<div class="login-methods">
<button
class="login-btn"
:class="{ active: kugouLoginMode === 'qr' }"
@click="startQrLogin('kugou')"
:disabled="kugouQr.loading"
>
<Icon icon="mdi:qrcode" />
扫码登录
</button>
<button
class="login-btn"
:class="{ active: kugouLoginMode === 'cookie' }"
@click="kugouLoginMode = 'cookie'"
>
<Icon icon="mdi:cookie" />
Cookie登录
</button>
</div>
<!-- QR Code -->
<div v-if="kugouLoginMode === 'qr'" class="qr-section">
<div v-if="kugouQr.loading" class="qr-loading">
<Icon icon="mdi:loading" class="spin" />
生成二维码中...
</div>
<div v-else-if="kugouQr.dataUrl" class="qr-wrap">
<img :src="kugouQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="kugouQr.status">
<template v-if="kugouQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用酷狗音乐APP扫码
</template>
<template v-else-if="kugouQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
</template>
<template v-else-if="kugouQr.status === 'confirmed'">
<Icon icon="mdi:check-circle" /> 登录成功!
</template>
<template v-else-if="kugouQr.status === 'expired'">
<Icon icon="mdi:refresh" /> 二维码已过期
<button class="btn-link" @click="startQrLogin('kugou')">重新生成</button>
</template>
</div>
</div>
</div>
<!-- Cookie -->
<div v-if="kugouLoginMode === 'cookie'" class="cookie-section">
<textarea
v-model="kugouCookie"
class="textarea"
placeholder="粘贴酷狗Cookie (token=...; userid=...)..."
rows="3"
/>
<button class="btn-primary btn-save" @click="saveCookie('kugou')">保存Cookie</button>
</div>
</div>
</section>
<!-- Audio Quality requires quality -->
@@ -792,6 +862,7 @@ const editForm = reactive({
const neteaseCookie = ref('');
const qqCookie = ref('');
const bilibiliCookie = ref('');
const kugouCookie = ref('');
const commandPrefix = ref('!');
// Audio quality
@@ -823,11 +894,13 @@ async function setQuality(q: string) {
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
const kugouLoginMode = ref<'qr' | 'cookie' | null>(null);
// Auth status
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
// QR state
interface QrState {
@@ -847,22 +920,28 @@ const qqQr = reactive<QrState>({
const bilibiliQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
const kugouQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
});
function getQrState(platform: string): QrState {
if (platform === 'bilibili') return bilibiliQr;
if (platform === 'kugou') return kugouQr;
return platform === 'netease' ? neteaseQr : qqQr;
}
async function checkAuthStatus() {
try {
const [nRes, qRes, bRes] = await Promise.all([
const [nRes, qRes, bRes, kRes] = await Promise.all([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
]);
Object.assign(neteaseAuth, nRes.data);
Object.assign(qqAuth, qRes.data);
Object.assign(bilibiliAuth, bRes.data);
Object.assign(kugouAuth, kRes.data);
} catch {
// API not ready
}
@@ -872,6 +951,7 @@ async function startQrLogin(platform: string) {
const qr = getQrState(platform);
if (platform === 'netease') neteaseLoginMode.value = 'qr';
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
else if (platform === 'kugou') kugouLoginMode.value = 'qr';
else qqLoginMode.value = 'qr';
// Stop existing poll
@@ -889,13 +969,15 @@ async function startQrLogin(platform: string) {
if (qrImg) {
qr.dataUrl = qrImg;
} else {
// QR codes must be dark-on-light to stay scannable. Do NOT invert the
// colours for the dark theme: many in-app scanners (notably the Kugou
// music app) cannot decode a light-on-dark QR, so a themed code looks
// fine on screen but silently fails to scan. The white quiet-zone frames
// it cleanly in dark mode anyway.
qr.dataUrl = await QRCode.toDataURL(qrUrl, {
width: 200,
margin: 2,
color: {
dark: store.theme === 'dark' ? '#ffffff' : '#000000',
light: store.theme === 'dark' ? '#2a2a2a' : '#ffffff',
},
color: { dark: '#000000', light: '#ffffff' },
});
}
@@ -1032,7 +1114,9 @@ async function toggleBot(botId: string, connected: boolean) {
}
async function saveCookie(platform: string) {
const cookie = platform === 'bilibili' ? bilibiliCookie.value : platform === 'netease' ? neteaseCookie.value : qqCookie.value;
const cookie = platform === 'bilibili' ? bilibiliCookie.value
: platform === 'kugou' ? kugouCookie.value
: platform === 'netease' ? neteaseCookie.value : qqCookie.value;
if (!cookie) return;
try {
await axios.post('/api/auth/cookie', { platform, cookie });
@@ -1557,6 +1641,7 @@ onUnmounted(() => {
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
if (kugouQr.pollTimer) clearInterval(kugouQr.pollTimer);
});
</script>
@@ -1698,6 +1783,10 @@ onUnmounted(() => {
&.bilibili-icon {
color: var(--brand-bilibili);
}
&.kugou-icon {
color: var(--brand-kugou);
}
}
.account-name {
+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;
}