Compare commits

..
Author SHA1 Message Date
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
TIANYAO ZHANG af326a37b7 Merge pull request #109 from ZHANGTIANYAO1/fix/player-elapsed-reactivity
fix(web): 播放器时间每帧更新、歌词同步 (closes #107)
2026-06-30 17:26:46 +08:00
31 changed files with 2077 additions and 430 deletions

No files matched your search

+24 -11
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1> <h1 align="center">TSMusicBot</h1>
<p align="center"> <p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板 <strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
</p> </p>
<p align="center"> <p align="center">
@@ -15,6 +15,7 @@
<img src="https://img.shields.io/badge/许可证-MIT-green" /> <img src="https://img.shields.io/badge/许可证-MIT-green" />
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" /> <img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" /> <img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" /> <img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
@@ -29,17 +30,17 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源 - **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步 - **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`) - **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带 - **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化 - **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级 - **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制 - **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲 - **播放历史** — 自动记录所有播放过的歌曲
@@ -300,19 +301,19 @@ sudo systemctl start tsmusicbot
- 端口(默认 9987,自托管或非标准端口请填写实际值) - 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称 - 机器人昵称
- 可选:服务器密码、默认频道 - 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲) 3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控 4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明 ### WebUI 页面说明
| 页面 | 功能 | | 页面 | 功能 |
|------|------| |------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 | | **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 | | **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 | | **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | | **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令 ### TeamSpeak 文字命令
@@ -322,9 +323,10 @@ sudo systemctl start tsmusicbot
|------|------| |------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) | | `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| | `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 | | `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) | | `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) | | `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) | | `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
@@ -338,9 +340,10 @@ sudo systemctl start tsmusicbot
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 | | `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) | | `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) | | `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 | | `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
@@ -407,6 +410,7 @@ teamspeak-music-bot/
│ │ ├── netease.ts # 网易云音乐适配器 │ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器 │ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp) │ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储 │ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动) │ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
@@ -457,8 +461,9 @@ teamspeak-music-bot/
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 | | **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **网易云 API** | NeteaseCloudMusicApi | | **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api | | **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) | | **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 | | **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
| **界面样式** | SCSS(YesPlayMusic 设计风格) | | **界面样式** | SCSS(YesPlayMusic 设计风格) |
| **图标** | @iconify/vue | | **图标** | @iconify/vue |
@@ -573,6 +578,12 @@ A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务
**Q:端口 3200 被占用?** **Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。
**Q:播放歌曲时报 FFmpeg EACCES 错误?** **Q:播放歌曲时报 FFmpeg EACCES 错误?**
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行: A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
```bash ```bash
@@ -727,6 +738,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。 - **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。 - **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。 - **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。 - **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢 ## 致谢
@@ -747,6 +759,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 | | [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 | | [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 | | [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
## 开源许可 ## 开源许可
+342 -352
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -17,7 +17,7 @@
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@honeybbq/teamspeak-client": "^0.2.1", "@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0", "@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10", "@sansenjian/qq-music-api": "~2.4.0",
"axios": "^1.14.0", "axios": "^1.14.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
@@ -28,7 +28,7 @@
"koa": "^3.2.0", "koa": "^3.2.0",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"NeteaseCloudMusicApi": "^4.30.0", "NeteaseCloudMusicApi": "~4.32.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1", "ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string; name: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
+7 -2
View File
@@ -36,6 +36,7 @@ export interface BotInstanceOptions {
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider; youtubeProvider: MusicProvider;
localProvider?: MusicProvider; localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -69,6 +70,7 @@ export class BotInstance extends EventEmitter {
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -98,6 +100,7 @@ export class BotInstance extends EventEmitter {
this.bilibiliProvider = options.bilibiliProvider; this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider; this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider; this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); this.logger = options.logger.child({ botId: this.id });
@@ -524,10 +527,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 === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider; if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider; if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
@@ -540,6 +544,7 @@ export class BotInstance extends EventEmitter {
if (flags.has("b")) return this.bilibiliProvider; if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider; if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider; if (flags.has("y")) return this.youtubeProvider;
if (flags.has("k")) return this.kugouProvider;
return this.neteaseProvider; return this.neteaseProvider;
} }
@@ -649,7 +654,7 @@ export class BotInstance extends EventEmitter {
private async cmdSearch(cmd: ParsedCommand): Promise<string> { private async cmdSearch(cmd: ParsedCommand): Promise<string> {
const p = this.config.commandPrefix; const p = this.config.commandPrefix;
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`; if (!cmd.args) return `Usage: ${p}search <name> [-q|-k|-b|-y]`;
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 8); const result = await provider.search(cmd.args, 8);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`; if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
+7 -1
View File
@@ -75,6 +75,7 @@ export class BotManager extends EventEmitter {
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -92,7 +93,8 @@ export class BotManager extends EventEmitter {
avatarStore: AvatarStore, avatarStore: AvatarStore,
permissions: PermissionStore, permissions: PermissionStore,
configPath: string, configPath: string,
localProvider?: MusicProvider localProvider?: MusicProvider,
kugouProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
this.bilibiliProvider = bilibiliProvider; this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider(); this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider; this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any // 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. // bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{ const referenceable = this.localProvider as Partial<{
@@ -137,6 +140,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -276,6 +280,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -329,6 +334,7 @@ export class BotManager extends EventEmitter {
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+1 -1
View File
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string; songName: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
coverUrl: string; coverUrl: string;
} }
+7 -1
View File
@@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js"; import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js"; import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js"; import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js"; import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js"; import { createPermissionStore } from "./data/permissions.js";
@@ -57,6 +58,7 @@ async function main() {
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl()); const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider(); const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR); const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider();
const cookieStore = createCookieStore(COOKIE_DIR); const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR); const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -66,6 +68,8 @@ async function main() {
if (qqCookie) qqProvider.setCookie(qqCookie); if (qqCookie) qqProvider.setCookie(qqCookie);
const bilibiliCookie = cookieStore.load("bilibili"); const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie); if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const kugouCookie = cookieStore.load("kugou");
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
const permissions = createPermissionStore(db.db); const permissions = createPermissionStore(db.db);
@@ -79,7 +83,8 @@ async function main() {
avatarStore, avatarStore,
permissions, permissions,
CONFIG_PATH, CONFIG_PATH,
localProvider localProvider,
kugouProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -90,6 +95,7 @@ async function main() {
qqProvider, qqProvider,
bilibiliProvider, bilibiliProvider,
localProvider, localProvider,
kugouProvider,
database: db, database: db,
avatarStore, avatarStore,
config, 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();
});
});
+34
View File
@@ -14,6 +14,32 @@ export interface ApiServerManager {
getQQMusicBaseUrl(): string; getQQMusicBaseUrl(): string;
} }
/**
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
* actionable operator guidance, or null when it isn't a recognised
* dependency/runtime mismatch. Exported for testing.
*
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
* that needs Node >=20.17 — either way the embedded server never binds, so
* every QQ request fails downstream with ECONNREFUSED on the API port.
*/
export function describeQqApiStartupError(err: unknown): string | null {
const e = (err ?? {}) as { code?: string; message?: string };
const code = String(e.code ?? "");
const msg = String(e.message ?? "");
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
return (
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
);
}
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
}
return null;
}
function isPortFree(port: number): Promise<boolean> { function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer(); const server = net.createServer();
@@ -100,11 +126,19 @@ export function createApiServerManager(
} }
} }
} catch (err) { } catch (err) {
const hint = describeQqApiStartupError(err);
if (hint) {
logger.error(
{ err },
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
);
} else {
logger.warn( logger.warn(
{ err }, { err },
"QQ Music API not available — QQ Music features may be limited" "QQ Music API not available — QQ Music features may be limited"
); );
} }
}
}, },
stop(): void { stop(): void {
+4 -4
View File
@@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
export interface CookieStore { export interface CookieStore {
save(platform: "netease" | "qq" | "bilibili", cookie: string): void; save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
load(platform: "netease" | "qq" | "bilibili"): string; load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
} }
export function createCookieStore(cookieDir: string): CookieStore { export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
} }
return { 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`); const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync( fs.writeFileSync(
filePath, 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`); const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return ""; if (!fs.existsSync(filePath)) return "";
try { try {
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } 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([]);
});
});
+1028
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string; album: string;
duration: number; // seconds duration: number; // seconds
coverUrl: string; 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 /** 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). */ * (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean; vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string; name: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
} }
export interface PlaylistDetail { export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string; artist: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
} }
export interface LyricLine { export interface LyricLine {
@@ -72,7 +72,7 @@ export interface AuthStatus {
} }
export interface MusicProvider { 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): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
+5 -1
View File
@@ -11,7 +11,8 @@ export function createAuthRouter(
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
cookieStore?: CookieStore cookieStore?: CookieStore,
kugouProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can // 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 { function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider; if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider; if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -65,6 +67,7 @@ export function createAuthRouter(
if (status === "confirmed") { if (status === "confirmed") {
const cookie = provider.getCookie(); const cookie = provider.getCookie();
const plat = (platform as string) === "bilibili" ? "bilibili" as const 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; : (platform as string) === "qq" ? "qq" as const : "netease" as const;
if (cookie && cookieStore) { if (cookie && cookieStore) {
cookieStore.save(plat, cookie); cookieStore.save(plat, cookie);
@@ -137,6 +140,7 @@ export function createAuthRouter(
const provider = getProvider(platform); const provider = getProvider(platform);
provider.setCookie(cookie); provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const const plat = platform === "bilibili" ? "bilibili" as const
: platform === "kugou" ? "kugou" as const
: platform === "qq" ? "qq" as const : "netease" as const; : platform === "qq" ? "qq" as const : "netease" as const;
if (cookieStore) { if (cookieStore) {
cookieStore.save(plat, cookie); cookieStore.save(plat, cookie);
+10 -2
View File
@@ -13,7 +13,8 @@ export function createMusicRouter(
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
localProvider?: MusicProvider, localProvider?: MusicProvider,
config?: BotConfig config?: BotConfig,
kugouProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider(); const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -26,6 +27,7 @@ export function createMusicRouter(
if (platform === "bilibili") return bilibiliProvider; if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider; if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider; if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -111,11 +113,12 @@ export function createMusicRouter(
return; return;
} }
const parsedLimit = parseInt(limit as string) || 20; 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), neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit), qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit), bilibiliProvider.search(q as string, parsedLimit),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }), 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 = [ const songs = [
@@ -123,6 +126,7 @@ export function createMusicRouter(
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []), ...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []), ...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
...(localResult.status === "fulfilled" ? localResult.value.songs : []), ...(localResult.status === "fulfilled" ? localResult.value.songs : []),
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
]; ];
const albums = [ const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
@@ -286,6 +290,7 @@ export function createMusicRouter(
qq: qqProvider.getQuality(), qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(), bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original", local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
}); });
}); });
@@ -305,6 +310,9 @@ export function createMusicRouter(
if (!platform || platform === "bilibili") { if (!platform || platform === "bilibili") {
bilibiliProvider.setQuality(quality); bilibiliProvider.setQuality(quality);
} }
if ((!platform || platform === "kugou") && kugouProvider) {
kugouProvider.setQuality(quality);
}
logger.info({ quality, platform }, "Audio quality changed"); logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality }); res.json({ success: true, quality });
}); });
+5 -4
View File
@@ -39,6 +39,7 @@ export function createPlayerRouter(
if (platform === "bilibili") return "-b"; if (platform === "bilibili") return "-b";
if (platform === "qq") return "-q"; if (platform === "qq") return "-q";
if (platform === "youtube") return "-y"; if (platform === "youtube") return "-y";
if (platform === "kugou") return "-k";
return ""; return "";
}; };
@@ -115,7 +116,7 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -290,7 +291,7 @@ export function createPlayerRouter(
// Use the bot's own provider lookup — it already knows about youtube, // Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not. // which the router's constructor params did not.
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -383,7 +384,7 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -614,7 +615,7 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
+3 -2
View File
@@ -39,6 +39,7 @@ export interface WebServerOptions {
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
localProvider: MusicProvider; localProvider: MusicProvider;
kugouProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string; configPath: string;
@@ -124,7 +125,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
); );
app.use( app.use(
"/api/music", "/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( app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database, options.botManager, logger, options.database,
@@ -132,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
)); ));
app.use( app.use(
"/api/auth", "/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)); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
+19 -9
View File
@@ -44,11 +44,15 @@
type="range" type="range"
min="0" min="0"
max="100" max="100"
:value="mobileVolume" :value="mobileVolumeDisplay"
class="m-volume-slider" 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>
</div> </div>
@@ -79,6 +83,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js'; import { usePlayerStore } from './stores/player.js';
import { useDecoupledSlider } from './composables/useDecoupledSlider.js';
import { useWebSocket } from './composables/useWebSocket.js'; import { useWebSocket } from './composables/useWebSocket.js';
import { useSession } from './composables/useSession.js'; import { useSession } from './composables/useSession.js';
import Navbar from './components/Navbar.vue'; import Navbar from './components/Navbar.vue';
@@ -99,7 +104,17 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const { connect } = useWebSocket(); const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong); 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 mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop']; const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
const mobileModeIcons: Record<string, string> = { const mobileModeIcons: Record<string, string> = {
@@ -126,11 +141,6 @@ function updateMobileProgress() {
mobileRaf = requestAnimationFrame(updateMobileProgress); mobileRaf = requestAnimationFrame(updateMobileProgress);
} }
function onMobileVolumeChange(e: Event) {
const volume = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(volume);
}
function toggleMobileVolume() { function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value; mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false; if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
+17 -5
View File
@@ -65,8 +65,12 @@
type="range" type="range"
min="0" min="0"
max="100" max="100"
:value="activeBot?.volume ?? 75" :value="volumeDisplay"
@input="onVolumeInput"
@change="onVolumeChange" @change="onVolumeChange"
@pointerup="onVolumeRelease"
@pointercancel="onVolumeRelease"
@blur="onVolumeRelease"
class="volume-slider" class="volume-slider"
/> />
</template> </template>
@@ -87,6 +91,7 @@ import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js'; import { useSession } from '../composables/useSession.js';
import { useDecoupledSlider } from '../composables/useDecoupledSlider.js';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue'; import Queue from './Queue.vue';
@@ -185,10 +190,17 @@ function togglePlay() {
} }
} }
function onVolumeChange(e: Event) { // Volume slider is decoupled from the per-frame rAF re-render so dragging the
const target = e.target as HTMLInputElement; // thumb isn't reset every frame (#111). See useDecoupledSlider.
store.setVolume(parseInt(target.value)); 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 modeOrder = ['seq', 'loop', 'random', 'rloop'] as const;
const modeIcons: Record<string, string> = { const modeIcons: Record<string, string> = {
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span> <span class="song-name">{{ song.name }}</span>
<span <span
class="platform-badge" 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'" :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' ? '本地' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -140,6 +140,11 @@ function formatDuration(seconds: number): string {
color: var(--color-primary); color: var(--color-primary);
} }
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+1
View File
@@ -24,6 +24,7 @@ import type { Source } from '../stores/player.js';
const LABELS: Record<Source, string> = { const LABELS: Record<Source, string> = {
netease: '网易云', netease: '网易云',
qq: 'QQ', qq: 'QQ',
kugou: '酷狗',
}; };
defineProps<{ 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; album: string;
duration: number; duration: number;
coverUrl: string; 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 { export interface BotStatus {
id: string; id: string;
@@ -100,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
// Home page cache, split by source // Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[] }, dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false }, authStatus: { netease: false, qq: false, kugou: false },
lastFetchTime: 0, lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user) // Favorited playlists (fetched from server, isolated per WebUI user)
@@ -157,6 +157,7 @@ export const usePlayerStore = defineStore('player', {
const s: Source[] = []; const s: Source[] = [];
if (this.authStatus.netease) s.push('netease'); if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq'); if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou');
return s; return s;
}, },
}, },
@@ -571,18 +572,23 @@ export const usePlayerStore = defineStore('player', {
// Always check auth status first — if it changed since the cached // Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the // fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch. // 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: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
]); ]);
const newAuth = { const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
}; };
const authChanged = 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.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq; this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou;
// Favorites are user-local and cheap; always refresh them, even on a // Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions. // home-data cache hit, so hearts stay correct across tabs/sessions.
@@ -621,15 +627,30 @@ export const usePlayerStore = defineStore('player', {
Promise.resolve(emptyPlaylists), 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 biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
const results = await Promise.allSettled([ const results = await Promise.allSettled([
...neteasePromises, ...neteasePromises,
...qqPromises, ...qqPromises,
...kugouPromises,
biliPromise, 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 = this.recommendPlaylists.netease =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : []; neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
@@ -643,6 +664,12 @@ export const usePlayerStore = defineStore('player', {
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : []; qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
this.userPlaylists.qq = this.userPlaylists.qq =
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : []; 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 // bilibili popular: keep previous value on failure (it's an anonymous endpoint
// unrelated to user auth state, and stale popular results are harmless) // unrelated to user auth state, and stale popular results are harmless)
if (bili.status === 'fulfilled') { 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 // cached for 5 minutes, otherwise the user has to hard-reload to
// recover when connectivity returns. // recover when connectivity returns.
const authOk = const authOk =
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled'; neAuthRes.status === 'fulfilled' ||
qqAuthRes.status === 'fulfilled' ||
kugouAuthRes.status === 'fulfilled';
if (authOk) { if (authOk) {
this.lastFetchTime = Date.now(); 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 { export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll(); const all = readAll();
const v = all[key]; 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 { 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-bilibili-15: rgba(0, 161, 214, 0.15);
--brand-youtube: #ff0000; --brand-youtube: #ff0000;
--brand-youtube-12: rgba(255, 0, 0, 0.12); --brand-youtube-12: rgba(255, 0, 0, 0.12);
--brand-kugou: #2ca2f9;
--brand-kugou-12: rgba(44, 162, 249, 0.12);
} }
// Dark theme (default) // Dark theme (default)
+18 -3
View File
@@ -41,13 +41,23 @@
</div> </div>
<Icon icon="mdi:play-circle" class="fm-play-icon" /> <Icon icon="mdi:play-circle" class="fm-play-icon" />
</div> </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>
<!-- 每日推荐 --> <!-- 每日推荐 -->
<section class="section" v-if="dailyAvailable.length > 0"> <section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
每日推荐 每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" /> <SourceTabs :model-value="dailySourceSafe" @update:model-value="dailySource = $event" :sources="dailyAvailable" />
</h2> </h2>
<div class="daily-grid"> <div class="daily-grid">
<div <div
@@ -67,7 +77,7 @@
<section class="section" v-if="recommendAvailable.length > 0"> <section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
推荐歌单 推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" /> <SourceTabs :model-value="recommendSourceSafe" @update:model-value="recommendSource = $event" :sources="recommendAvailable" />
</h2> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
@@ -108,7 +118,7 @@
<h2 class="section-title"> <h2 class="section-title">
我的歌单 我的歌单
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span> <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> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
@@ -173,6 +183,7 @@ const userPlaylistsExpanded = ref(false);
const recommendAvailable = computed<Source[]>(() => { const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease']; const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq'); if (store.authStatus.qq) s.push('qq');
if (store.authStatus.kugou) s.push('kugou');
return s; return s;
}); });
const dailyAvailable = computed<Source[]>(() => store.availableSources); const dailyAvailable = computed<Source[]>(() => store.availableSources);
@@ -358,6 +369,10 @@ onMounted(() => {
&.qq { &.qq {
background: linear-gradient(135deg, var(--brand-qq), #17a2b8); background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
} }
&.kugou {
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
}
} }
.fm-icon { .fm-icon {
+1 -1
View File
@@ -28,7 +28,7 @@
<h2 class="section-title"> <h2 class="section-title">
我的歌单 我的歌单
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span> <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> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
+17 -5
View File
@@ -72,6 +72,11 @@
:class="{ active: selectedSource === 'bilibili' }" :class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'" @click="selectedSource = 'bilibili'"
>B站</button> >B站</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'kugou' }"
@click="selectedSource = 'kugou'"
>酷狗</button>
<button <button
v-if="hasLocalSongs" v-if="hasLocalSongs"
class="source-btn" class="source-btn"
@@ -89,7 +94,7 @@
单曲<span class="tab-count">{{ filteredSongs.length }}</span> 单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button> </button>
<button <button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'" v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab" class="tab"
:class="{ active: activeTab === 'albums' }" :class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'" @click="activeTab = 'albums'"
@@ -97,7 +102,7 @@
专辑<span class="tab-count">{{ filteredAlbums.length }}</span> 专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button> </button>
<button <button
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'" v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
class="tab" class="tab"
:class="{ active: activeTab === 'playlists' }" :class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'" @click="activeTab = 'playlists'"
@@ -182,12 +187,12 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source'; const SOURCE_STORAGE_KEY = 'search-source';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local'; type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
function loadSource(): SearchSource { function loadSource(): SearchSource {
try { try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY); 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 */ } } catch { /* localStorage blocked */ }
return 'netease'; return 'netease';
} }
@@ -232,7 +237,7 @@ watch(selectedSource, (src) => {
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。 // B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') { if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
activeTab.value = 'songs'; activeTab.value = 'songs';
} }
}); });
@@ -351,6 +356,7 @@ function badgeLabel(platform: string): string {
if (platform === 'bilibili') return 'B站'; if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube'; if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地'; if (platform === 'local') return '本地';
if (platform === 'kugou') return '酷狗';
return '网易云'; return '网易云';
} }
@@ -359,6 +365,7 @@ function badgeClass(platform: string): string {
if (platform === 'bilibili') return 'badge-bilibili'; if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube'; if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local'; if (platform === 'local') return 'badge-local';
if (platform === 'kugou') return 'badge-kugou';
return 'badge-netease'; return 'badge-netease';
} }
@@ -645,6 +652,11 @@ onMounted(() => {
color: var(--color-primary); color: var(--color-primary);
} }
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.fav-badge { .fav-badge {
position: absolute; position: absolute;
top: 8px; top: 8px;
+95 -6
View File
@@ -377,6 +377,76 @@
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button> <button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
</div> </div>
</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> </section>
<!-- Audio Quality requires quality --> <!-- Audio Quality requires quality -->
@@ -792,6 +862,7 @@ const editForm = reactive({
const neteaseCookie = ref(''); const neteaseCookie = ref('');
const qqCookie = ref(''); const qqCookie = ref('');
const bilibiliCookie = ref(''); const bilibiliCookie = ref('');
const kugouCookie = ref('');
const commandPrefix = ref('!'); const commandPrefix = ref('!');
// Audio quality // Audio quality
@@ -823,11 +894,13 @@ async function setQuality(q: string) {
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null); const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
const qqLoginMode = ref<'qr' | 'cookie' | null>(null); const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null); const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
const kugouLoginMode = ref<'qr' | 'cookie' | null>(null);
// Auth status // Auth status
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
// QR state // QR state
interface QrState { interface QrState {
@@ -847,22 +920,28 @@ const qqQr = reactive<QrState>({
const bilibiliQr = reactive<QrState>({ const bilibiliQr = reactive<QrState>({
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null, 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 { function getQrState(platform: string): QrState {
if (platform === 'bilibili') return bilibiliQr; if (platform === 'bilibili') return bilibiliQr;
if (platform === 'kugou') return kugouQr;
return platform === 'netease' ? neteaseQr : qqQr; return platform === 'netease' ? neteaseQr : qqQr;
} }
async function checkAuthStatus() { async function checkAuthStatus() {
try { 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: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }), axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
]); ]);
Object.assign(neteaseAuth, nRes.data); Object.assign(neteaseAuth, nRes.data);
Object.assign(qqAuth, qRes.data); Object.assign(qqAuth, qRes.data);
Object.assign(bilibiliAuth, bRes.data); Object.assign(bilibiliAuth, bRes.data);
Object.assign(kugouAuth, kRes.data);
} catch { } catch {
// API not ready // API not ready
} }
@@ -872,6 +951,7 @@ async function startQrLogin(platform: string) {
const qr = getQrState(platform); const qr = getQrState(platform);
if (platform === 'netease') neteaseLoginMode.value = 'qr'; if (platform === 'netease') neteaseLoginMode.value = 'qr';
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr'; else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
else if (platform === 'kugou') kugouLoginMode.value = 'qr';
else qqLoginMode.value = 'qr'; else qqLoginMode.value = 'qr';
// Stop existing poll // Stop existing poll
@@ -889,13 +969,15 @@ async function startQrLogin(platform: string) {
if (qrImg) { if (qrImg) {
qr.dataUrl = qrImg; qr.dataUrl = qrImg;
} else { } 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, { qr.dataUrl = await QRCode.toDataURL(qrUrl, {
width: 200, width: 200,
margin: 2, margin: 2,
color: { color: { dark: '#000000', light: '#ffffff' },
dark: store.theme === 'dark' ? '#ffffff' : '#000000',
light: store.theme === 'dark' ? '#2a2a2a' : '#ffffff',
},
}); });
} }
@@ -1032,7 +1114,9 @@ async function toggleBot(botId: string, connected: boolean) {
} }
async function saveCookie(platform: string) { 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; if (!cookie) return;
try { try {
await axios.post('/api/auth/cookie', { platform, cookie }); await axios.post('/api/auth/cookie', { platform, cookie });
@@ -1557,6 +1641,7 @@ onUnmounted(() => {
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer); if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer); if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer); if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
if (kugouQr.pollTimer) clearInterval(kugouQr.pollTimer);
}); });
</script> </script>
@@ -1698,6 +1783,10 @@ onUnmounted(() => {
&.bilibili-icon { &.bilibili-icon {
color: var(--brand-bilibili); color: var(--brand-bilibili);
} }
&.kugou-icon {
color: var(--brand-kugou);
}
} }
.account-name { .account-name {