Compare commits

..
Author SHA1 Message Date
saopig1andClaude Opus 4.8 77d71fe418 docs(spotify): note intentional Spotify omission from unified /search/all (stage 1)
Spotify tracks are metadata-only until the librespot audio backend lands, so
they are surfaced only from the dedicated Spotify tab, not the default
all-sources search. Conscious decision from the whole-branch review (#112).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:32:08 +08:00
saopig1andClaude Opus 4.8 50bdaae26a feat(spotify): frontend plumbing (source tab, badge, auth status)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:22:45 +08:00
saopig1andClaude Opus 4.8 e1171dbcfe feat(spotify): expose provider through web music/auth routers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:19:22 +08:00
saopig1andClaude Opus 4.8 9a9f68c446 feat(spotify): wire provider through manager/instance; skip playback sentinel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:13:22 +08:00
saopig1andClaude Opus 4.8 848b3931b8 feat(spotify): config block (disabled by default) + sanitize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:04:31 +08:00
saopig1 828fd21e29 feat(spotify): SpotifyProvider (search/browse; playback sentinel) 2026-07-01 23:59:41 +08:00
saopig1andClaude Opus 4.8 6c16e2d966 feat(spotify): Web API client + catalog mappers, add spotify platform
Adds src/music/spotify/webapi.ts (client-credentials token, catalog
mappers, 429 retry) + tests, and threads the new "spotify" platform id
through the type unions in provider.ts and database.ts. Also widens the
downstream QueuedSong.platform union (audio/queue.ts) and the
getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean;
these two are the necessary call-site fixes for the new union member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:52:22 +08:00
saopig1andClaude Opus 4.8 b352d71528 docs(spotify): stage 1 implementation plan (metadata + provider + wiring) (#112)
Bite-sized TDD plan for the first shippable increment: Spotify becomes a
searchable/browsable source (Web API), with playback cleanly reporting
"not playable yet". Adversarially verified against the codebase (3 critics)
and fixed: getProviderFor signature, pre-existing config.test.ts imports,
all three BotInstance sites, enabled-gate safety, 429 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:04:35 +08:00
saopig1andClaude Opus 4.8 92bdfcb294 docs(spotify): design spec for optional hybrid librespot audio source (#112)
Adds the approved design for a new optional `spotify` MusicProvider that
streams real Spotify audio via a librespot-family sidecar behind one
SpotifyAudioBackend interface (go-librespot on Linux/Docker, Rust librespot
on Windows), with metadata via the Spotify Web API. Disabled by default,
opt-in, Premium-only, ToS-risk warned. Includes verified research appendix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:26:07 +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
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
saopig1andClaude Opus 4.8 45f5d236c1 fix(web): tick player time every frame and keep lyrics in sync (#107)
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:17:53 +08:00
42 changed files with 4189 additions and 445 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 许可) |
## 开源许可 ## 开源许可
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,202 @@
# Spotify audio source (optional, hybrid librespot) — design spec
- **Issue:** [#112 — Support for Spotify audio source](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/112)
- **Date:** 2026-07-01
- **Status:** Approved (design) — pending implementation plan
- **Chosen approach:** Hybrid — real Spotify streaming via a librespot-family sidecar, with **go-librespot on Linux/Docker** and **Rust librespot on Windows**, behind one backend interface.
---
## 1. Summary
Add `spotify` as a new **optional** `MusicProvider`. Metadata (search / track / album / playlist) is read from the official **Spotify Web API**. Audio is the *real* Spotify stream, produced by a librespot-family sidecar and piped through ffmpeg into the bot's existing voice path.
The feature is **disabled by default**, opt-in, and **requires the user's own Spotify Premium account**. librespot is an unofficial, reverse-engineered client and using it **violates Spotify's Terms of Service** (account-ban risk). This is surfaced to the user as an explicit experimental warning, and no credentials are ever bundled.
If the feature is disabled, unauthenticated, or the sidecar binary is missing, the provider disables itself gracefully — exactly like `YouTubeProvider` when `yt-dlp` is absent (empty results, greyed-out in the UI, no crash).
## 2. Goals / Non-goals
**Goals**
- First-class `spotify` source: search, play, playlist/album import, lyrics best-effort, login status.
- Real Spotify audio (Premium), not a YouTube match.
- Cross-platform: works on the project's three deployments — native Windows one-click, Linux systemd, Docker.
- Strictly optional and safe-by-default; zero impact when off.
- Mixed queues keep working (Spotify tracks interleaved with netease/qq/etc.).
**Non-goals**
- No free-tier audio (Premium is mandatory for librespot streaming).
- No bundled Spotify credentials or shared Developer app.
- No replacement of, or change to, existing sources' behavior.
- No CI e2e against live Spotify (requires Premium; manual only).
## 3. Backend selection
`spotify.backend: "auto" | "go-librespot" | "librespot"` (default `auto`).
| Platform | `auto` resolves to | Why |
|---|---|---|
| Windows | `librespot` (Rust) | go-librespot has **no** Windows binary and its FIFO capture is POSIX-only |
| Linux / Docker | `go-librespot` (fallback `librespot`) | clean REST play-by-URI + prebuilt Linux binary |
| macOS | `librespot` (or `go-librespot` if built) | no go-librespot macOS binary published |
The split is **platform-determined**, not a per-run user toggle (a user may still force one via config if they have the binary).
## 4. Architecture
### 4.1 The seam — `SpotifyAudioBackend`
New file `src/music/spotify/backend.ts`:
```ts
export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; }
export interface SpotifyAudioBackend {
start(): Promise<void>; // launch sidecar + resampling ffmpeg
stop(): void; // tear everything down
isReady(): boolean; // device online & streamable
playTrack(uri: string): Promise<void>; // begin ONE track (spotify:track:...)
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
getPcmStream(): Readable; // 48kHz s16le stereo (post-ffmpeg)
getPosition(): number; // ms into current track
on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void;
on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void;
on(event: "ready" | "error", cb: (arg?: unknown) => void): void;
}
```
Both implementations emit the **same** 48 kHz s16le stereo PCM and the **same** events, so everything above the seam (provider, controller, player, UI) is backend-agnostic.
### 4.2 `GoLibrespotBackend` (Linux/Docker) — `src/music/spotify/go-librespot.ts`
- Writes a `config.yml` with `server: { enabled: true, address: 'localhost', port: <p> }`, `audio_backend: 'pipe'`, `audio_output_pipe: '<fifo>'`, `audio_output_pipe_format: 's16le'`, `bitrate: 320`, credentials block.
- `start()`: `mkfifo <fifo>` → **spawn the FIFO-reading ffmpeg first** → then spawn go-librespot. (FIFO open ordering is mandatory: go-librespot opens the write end with `O_WRONLY|O_NONBLOCK` and errors `ENXIO` if no reader exists yet.) ffmpeg: `-f s16le -ar 44100 -ac 2 -i <fifo> -f s16le -ar 48000 -ac 2 -`. ffmpeg stdout = `getPcmStream()`.
- Control (REST): `POST /player/play {uri}`, `POST /player/pause`, `POST /player/resume`, `POST /player/seek {position}`. `GET /status` for position.
- Events (WebSocket `/events`): `metadata` → `metadata`; `not_playing` → `trackEnded` (track boundaries can NOT be read from the FIFO — it is gapless/continuous and never EOFs between tracks).
- Metadata token: go-librespot exposes `POST /token` (first-party session token) and a `/web-api/<path>` proxy to `api.spotify.com` — so on this backend a separate Spotify Developer app is **optional**.
- Recovery: on reader death go-librespot's write returns `EPIPE` and closes the pipe output; backend restarts the ffmpeg reader + reactivates the device.
### 4.3 `RustLibrespotBackend` (Windows / cross-platform) — `src/music/spotify/rust-librespot.ts`
Rust librespot is a **passive Spotify Connect receiver**; the bot acts as the **Connect controller** via the Spotify Web API.
- `start()`: spawn `librespot --backend pipe --name "<deviceName>" --bitrate 320 --cache <dir> [--access-token <tok> | --enable-oauth]`. With **no `--device`**, librespot writes raw PCM (**s16le, 44100 Hz, stereo**) to **stdout** (cross-platform; verified against `pipe.rs` — `None => Box::new(io::stdout())`). Pipe stdout → ffmpeg `-f s16le -ar 44100 -ac 2 -i pipe:0 -f s16le -ar 48000 -ac 2 -` → `getPcmStream()`. Do **not** pass `--passthrough` (that emits raw Ogg, not PCM).
- `playTrack(uri)`: `GET /v1/me/player/devices` → find our `device_id` by `deviceName` → `PUT /v1/me/player/play?device_id={id}` body `{uris:[uri]}`. `pause/resume/seek` → `PUT /v1/me/player/{pause,play,seek}`.
- `trackEnded`: poll `GET /v1/me/player` (is_playing → false / `item` changed / `progress_ms ≈ duration_ms`) plus optional `--onevent` hook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command `202/404` flakiness.
- Requires a **user OAuth token** with scopes `streaming user-read-playback-state user-modify-playback-state user-read-currently-playing` (+ `playlist-read-private playlist-read-collaborative` for user playlists).
## 5. Shared subsystems
### 5.1 Metadata — `src/music/spotify/webapi.ts`
Thin `axios` client mapping Spotify catalog objects → the bot's `Song` / `Playlist` / `Album`:
- `GET /v1/search?type=track,album,playlist&q=…`
- `GET /v1/tracks/{id}`, `GET /v1/albums/{id}/tracks`, `GET /v1/playlists/{id}/tracks`
All confirmed still available with a normal token **after** Spotify's 2024-11-27 cut (that cut removed related-artists, recommendations, audio-features/analysis, featured/category playlists, and `preview_url` — none of which we use). Token source is pluggable: user Developer-app token (primary) or go-librespot `/web-api` proxy (go-librespot path). Handle `429 Retry-After` (rolling 30 s window; dev-mode quota).
### 5.2 Auth — `src/music/spotify/auth.ts`
One web-UI **Authorization-Code + PKCE** login.
- **Primary:** the user registers their own Spotify Developer app (Client ID [+ Secret] + redirect URI) — reliable, ToS-cleaner. The resulting access token drives metadata + Web-API control; the refresh token is persisted (credential store); access tokens (~1 h) auto-refresh. The same token bootstraps librespot via `--access-token`, after which librespot caches reusable credentials — **one user-facing login**.
- **Fallbacks:** librespot `--enable-oauth` (built-in client `65b708073fc0480ea92a077233ca87bd`, redirect `http://127.0.0.1:8898/login`) or go-librespot interactive login (`http://127.0.0.1:36842/login?code=…`) as a separate one-time step; go-librespot `/web-api` proxy when no Developer app is provided.
- Username/password is **dead** (removed by Spotify in 2024); do not implement it.
### 5.3 Provider — `src/music/spotify/provider.ts`
`SpotifyProvider implements MusicProvider` with `platform: "spotify"`:
- `search`, `getSongDetail`, `getPlaylistSongs`, `getAlbumSongs`, `getLyrics` (best-effort/empty), `getRecommendPlaylists` → Web API.
- `getAuthStatus()` reports login state **and** backend/binary availability (drives greying-out in UI, like YouTube).
- `getSongUrl(id)` returns a **sentinel** (`{ url: "spotify:track:<id>" }`) — actual playback is via the backend/controller, not a URL. `instance.ts` recognizes the sentinel and routes to the `SpotifyController`.
- QR-code login methods are no-ops; Spotify uses the OAuth card instead.
## 6. Queue / player / instance integration
- **`SpotifyController`** (`src/music/spotify/controller.ts`, one per bot): owns the chosen backend and the Web-API/auth clients; exposes `playTrack/pause/resume/seek/stop` and forwards `trackEnded`/`metadata`.
- **`AudioPlayer` — new external-PCM mode:** add `playPcmStream(readable, { onExternalEnd })` that feeds the existing `pcmBuffer` → 20 ms frame loop → encoder → `frame` path **without spawning a url-ffmpeg**. For Spotify, `trackEnd` is driven by the backend's `trackEnded` event (the librespot→ffmpeg pipeline is long-lived and does not exit per song). `pause/resume/seek` on a Spotify song are routed to the backend by `instance.ts` (and gate frame emission locally for crisp UI state).
- **`instance.ts`:** `getProviderFor("spotify")`, a `-s` command flag in `getProvider(flags)`. When a dequeued `song.platform === "spotify"`: ensure the controller/backend is started + device active, `playTrack(uri)`, attach the player to the backend PCM. On `trackEnded` → advance the queue. When a **non-Spotify** song is next, **pause the sidecar** (so it doesn't buffer ahead) and use the normal `player.play(url)` path. This preserves one-track-at-a-time on-demand playback and mixed-source queues.
- Real-time pacing is guaranteed by the voice consumer: TS voice pulls 20 ms frames at real time → the player reads PCM at real time → ffmpeg's read of the sidecar stalls → sidecar backpressure pauses decode. (This is why go-librespot's pull model avoids the classic librespot "plays too fast / skips" bug; Rust librespot to stdout is likewise paced by our reads.)
## 7. Config, opt-in & safety
`BotConfig.spotify` (all default-off), added to `getDefaultConfig()` and sanitized in `loadConfig()`:
```ts
spotify: {
enabled: false,
backend: "auto", // "auto" | "go-librespot" | "librespot"
clientId: "", // user's Developer app (optional on go-librespot path)
clientSecret: "", // optional — PKCE needs none; only for confidential/client-credentials flows
deviceName: "TSMusicBot",
bitrate: 320, // 96 | 160 | 320
}
```
Inert unless `enabled` **and** logged-in **and** a resolvable binary. First-run/settings shows the experimental + ToS + Premium + own-credentials warning. Never store or transmit shared secrets.
## 8. Web UI
- Add `spotify` to `platform` and `Source` unions (`web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`).
- `SourceTabs.vue`: add "Spotify" tab; `SongCard.vue`: green **#1DB954** badge; `variables.scss`: `--brand-spotify` tokens.
- `stores/player.ts`: extend `authStatus` / `recommendPlaylists` / `dailySongs` / `userPlaylists` maps + the auth/recommend fetch fan-out.
- Settings: a **Spotify login card distinct from the QR cards** — "Connect Spotify" OAuth button, optional Client ID/Secret fields, backend/binary status indicator, and the risk disclaimer.
## 9. Binary / dependency resolution — `src/music/spotify/binary.ts`
Mirror `findYtDlp()`: resolve `go-librespot` / `librespot(.exe)` from `bin/` then PATH; positive availability cached, negative retried (install-while-running).
- **No prebuilt Rust librespot exists** (source-only: `cargo install librespot`, distro/`scoop`/`choco`, or a binary dropped in `bin/`).
- **go-librespot** ships **Linux-only** assets (`go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz`, ~6 MB, at `github.com/devgianlu/go-librespot/releases`). Docker build downloads the correct Linux asset; optional runtime auto-download (like yt-dlp). go-librespot is **GPL-3.0** → sidecar = mere aggregation (does not infect the Node code); ship its license text + a source offer.
- Unresolved binary → source disabled with an actionable "install X / see docs" message.
## 10. Testing
Vitest, mocking child processes and HTTP:
- Web-API response → `Song/Playlist/Album` mapping.
- `chooseBackend()` per platform/config.
- Auth: PKCE challenge, token refresh, credential persistence.
- Config load/sanitize (defaults, hand-edited/corrupt input).
- `SpotifyProvider` methods (mock webapi).
- Player external-PCM path: feed a fake `Readable` → assert `frame` emission + `trackEnd` on external end.
- go-librespot `/events` → `trackEnded` translation; Rust-backend Web-API poll → `trackEnded`.
- e2e against live Spotify is **manual & documented** (Premium required), not CI.
## 11. Files touched
**New:** `src/music/spotify/{provider,backend,go-librespot,rust-librespot,webapi,auth,controller,binary}.ts` (+ `.test.ts`).
**Edited (backend):** `src/music/provider.ts` (union: `Song`/`Playlist`/`Album`/`MusicProvider`), `src/index.ts`, `src/bot/manager.ts`, `src/bot/instance.ts` (router + `-s` flag + spotify routing), `src/audio/player.ts` (external-PCM mode), `src/data/config.ts`, `src/music/auth.ts` (credential store union), `src/web/api/auth.ts`, `src/web/api/music.ts` (routers + search aggregation), `src/web/server.ts`, `src/data/database.ts` (platform).
**Edited (frontend):** `web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`, `web/src/components/SourceTabs.vue`, `web/src/components/SongCard.vue`, `web/src/styles/variables.scss`.
**Docs:** `README.md` (Spotify section + warnings + install notes).
## 12. Staged rollout
1. **Metadata + provider + config + UI plumbing** (no audio): `spotify` source searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar.
2. **go-librespot backend (Linux/Docker):** real playback on Linux; REST + FIFO + WebSocket.
3. **Rust librespot backend (Windows):** stdout PCM + Web-API Connect control.
4. **Polish:** recovery/watchdog, docs, binary auto-download, README.
## 13. Risks & open questions
- **Rust librespot control is the top risk** — Connect device visibility/latency, poll-based track-end. Mitigation: retry/backoff + watchdog; degrade to "skipped" on repeated failure.
- **One-OAuth-token bootstraps librespot** (`--access-token` from the user's own app client) is *plausible but unverified* — may fall back to two one-time logins. Verify with a spike in stage 3.
- **Windows go-librespot is impossible** (FIFO) → Windows always uses Rust librespot.
- **Token scope from librespot's built-in client** may not include `user-modify-playback-state`; if so, a user Developer app is required for the Rust/Windows control path.
- Large surface area → staged rollout above; each stage independently shippable.
## Appendix A — Verified technical facts (with sources)
go-librespot API (`devgianlu/go-librespot`, v0.7.x):
- REST: `POST /player/{play,pause,resume,playpause,stop,next,prev,seek,volume,add_to_queue}`, `GET /status`, `GET /` (`{playback_ready}`), `POST /token`, `GET|POST /web-api/<path>`. `POST /player/play` body `{uri, skip_to_uri?, paused?}`. Server enabled only when `server.enabled: true`.
- WebSocket `/events` envelopes `{type, data}`; types include `metadata, will_play, playing, paused, not_playing, stopped, seek, volume, active, inactive`. Track-end = `not_playing`.
- Pipe backend: continuous raw PCM, no header, 44100 Hz stereo, format `s16le|s32le|f32le`; FIFO opened once; POSIX-only. Backpressure real (blocks when reader stalls).
- Sources: `github.com/devgianlu/go-librespot` (README, `api-spec.yml`, `daemon/api_server.go`, `output/driver-pipe.go`).
Rust librespot (`librespot-org/librespot`, v0.8.0, MIT):
- `--backend pipe` with no `--device` → raw PCM to **stdout**; default S16 / 44100 / stereo; `--format` for higher bit depth; `--passthrough` = raw Ogg (do not use). Passive Connect receiver — no play-by-URI; control via Web API Connect. `--onevent` = read-only hook. No prebuilt binaries.
- Sources: `github.com/librespot-org/librespot` wiki (Audio-Backends, Options), `librespot_playback/audio_backend/pipe.rs`, `docs/authentication.md`.
Spotify auth & Web API:
- Username/password removed (2024); use OAuth (Auth-Code+PKCE) or Zeroconf. Premium required for librespot audio.
- Client-Credentials token authorizes `/v1/search` + public track/album/playlist GETs; 2024-11-27 cut did **not** touch these. Token endpoint `POST https://accounts.spotify.com/api/token` (`grant_type=client_credentials`, Basic base64(id:secret), `expires_in=3600`, no refresh). 429 on a rolling 30 s window.
- Sources: `developer.spotify.com` (Web API docs; 2024-11-27 blog), `librespot` `docs/authentication.md`.
+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" | "spotify";
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
+8
View File
@@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
expect(ctx.executeCommand).toHaveBeenCalledTimes(1); expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
}); });
}); });
describe("BotInstance.getProviderFor — spotify routing", () => {
it("getProviderFor routes 'spotify' to the injected spotify provider", () => {
const spotify = { platform: "spotify" } as any;
const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any;
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
});
});
+24 -2
View File
@@ -23,6 +23,7 @@ import {
occupancyFromClientList, occupancyFromClientList,
shouldResumeOnReturn, shouldResumeOnReturn,
} from "./auto-pause.js"; } from "./auto-pause.js";
import { isSpotifyUri } from "../music/spotify/webapi.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */ /** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -36,6 +37,8 @@ export interface BotInstanceOptions {
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider; youtubeProvider: MusicProvider;
localProvider?: MusicProvider; localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -69,6 +72,8 @@ 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 spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -98,6 +103,8 @@ 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.spotifyProvider = options.spotifyProvider ?? 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 +531,12 @@ export class BotInstance extends EventEmitter {
} }
} }
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider { getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): 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;
if (platform === "spotify") return this.spotifyProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
@@ -540,6 +549,8 @@ 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;
if (flags.has("s")) return this.spotifyProvider;
return this.neteaseProvider; return this.neteaseProvider;
} }
@@ -576,6 +587,17 @@ export class BotInstance extends EventEmitter {
); );
return false; return false;
} }
// Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl
// returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user
// and skip so the queue keeps moving. `sendTextMessage` is the same
// channel-message helper the command handlers use elsewhere in this file.
if (isSpotifyUri(result.url)) {
this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping");
await this.tsClient.sendTextMessage(
"⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。"
);
return false;
}
song.url = result.url; song.url = result.url;
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration; this.effectiveDuration = result.trialDuration ?? song.duration;
@@ -649,7 +671,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}`;
+13 -1
View File
@@ -75,6 +75,8 @@ 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 spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -92,7 +94,9 @@ export class BotManager extends EventEmitter {
avatarStore: AvatarStore, avatarStore: AvatarStore,
permissions: PermissionStore, permissions: PermissionStore,
configPath: string, configPath: string,
localProvider?: MusicProvider localProvider?: MusicProvider,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -100,6 +104,8 @@ 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;
this.spotifyProvider = spotifyProvider ?? 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 +143,8 @@ 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,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -276,6 +284,8 @@ 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,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -329,6 +339,8 @@ 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,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+58
View File
@@ -205,3 +205,61 @@ describe("adminGroups normalization", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]); expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
}); });
}); });
describe("spotify config", () => {
it("defaults are present and disabled", () => {
const c = getDefaultConfig();
expect(c.spotify).toEqual({
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
});
});
it("loadConfig coerces bad spotify values back to safe defaults", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 },
})
);
const c = loadConfig(p);
expect(c.spotify.enabled).toBe(false); // non-boolean → false
expect(c.spotify.backend).toBe("auto"); // invalid enum → auto
expect(c.spotify.bitrate).toBe(320); // invalid → 320
expect(c.spotify.clientId).toBe(""); // non-string → ""
expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default
});
it("loadConfig preserves valid spotify values", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: {
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
},
})
);
const c = loadConfig(p);
expect(c.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
});
});
});
+39
View File
@@ -9,6 +9,15 @@ export interface GuestModeConfig {
permissions: GuestPermissions; permissions: GuestPermissions;
} }
export interface SpotifyConfig {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string;
deviceName: string;
bitrate: number;
}
export interface BotConfig { export interface BotConfig {
webPort: number; webPort: number;
locale: "zh" | "en"; locale: "zh" | "en";
@@ -33,6 +42,7 @@ export interface BotConfig {
// behind HTTPS-terminating proxies. // behind HTTPS-terminating proxies.
trustProxy: boolean; trustProxy: boolean;
guestMode: GuestModeConfig; guestMode: GuestModeConfig;
spotify: SpotifyConfig;
} }
export function getDefaultConfig(): BotConfig { export function getDefaultConfig(): BotConfig {
@@ -69,6 +79,14 @@ export function getDefaultConfig(): BotConfig {
playCollection: false, playCollection: false,
}, },
}, },
spotify: {
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
},
}; };
} }
@@ -118,11 +136,32 @@ export function loadConfig(path: string): BotConfig {
) )
: defaults.adminGroups; : defaults.adminGroups;
const partialSp = (partial.spotify ?? {}) as Partial<SpotifyConfig>;
const validBackends = ["auto", "go-librespot", "librespot"] as const;
const validBitrates = [96, 160, 320];
const spotify: SpotifyConfig = {
enabled: partialSp.enabled === true,
backend: (validBackends as readonly string[]).includes(partialSp.backend as string)
? (partialSp.backend as SpotifyConfig["backend"])
: defaults.spotify.backend,
clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId,
clientSecret:
typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret,
deviceName:
typeof partialSp.deviceName === "string" && partialSp.deviceName.trim()
? partialSp.deviceName
: defaults.spotify.deviceName,
bitrate: validBitrates.includes(partialSp.bitrate as number)
? (partialSp.bitrate as number)
: defaults.spotify.bitrate,
};
return { return {
...defaults, ...defaults,
...partial, ...partial,
adminGroups, adminGroups,
guestMode: gm, guestMode: gm,
spotify,
}; };
} catch { } catch {
return defaults; return defaults;
+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" | "spotify";
coverUrl: string; coverUrl: string;
} }
+18 -1
View File
@@ -8,6 +8,8 @@ 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 { SpotifyProvider } from "./music/spotify/provider.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 +59,15 @@ 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 spotifyProvider = new SpotifyProvider();
// Safety gate (spec §7): the source is inert unless EXPLICITLY enabled.
// Only feed credentials when enabled — otherwise the provider has no creds,
// hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false,
// so setting a Client ID/Secret alone (enabled:false) never activates Spotify.
if (config.spotify.enabled && config.spotify.clientId) {
spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret);
}
const cookieStore = createCookieStore(COOKIE_DIR); const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR); const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -66,6 +77,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 +92,9 @@ async function main() {
avatarStore, avatarStore,
permissions, permissions,
CONFIG_PATH, CONFIG_PATH,
localProvider localProvider,
kugouProvider,
spotifyProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -90,6 +105,8 @@ async function main() {
qqProvider, qqProvider,
bilibiliProvider, bilibiliProvider,
localProvider, localProvider,
kugouProvider,
spotifyProvider,
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();
});
});
+38 -4
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,10 +126,18 @@ export function createApiServerManager(
} }
} }
} catch (err) { } catch (err) {
logger.warn( const hint = describeQqApiStartupError(err);
{ err }, if (hint) {
"QQ Music API not available — QQ Music features may be limited" 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"; 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" | "spotify";
/** 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" | "spotify";
} }
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" | "spotify";
} }
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" | "spotify";
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>;
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { SpotifyProvider } from "./provider.js";
import { SpotifyWebApi } from "./webapi.js";
function fakeApi(over: Partial<SpotifyWebApi> = {}): SpotifyWebApi {
return {
hasCreds: () => true,
setCreds: vi.fn(),
search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }),
getTrack: vi.fn().mockResolvedValue(null),
getAlbumTracks: vi.fn().mockResolvedValue([]),
getPlaylistTracks: vi.fn().mockResolvedValue([]),
...over,
} as unknown as SpotifyWebApi;
}
describe("SpotifyProvider", () => {
it("has platform 'spotify'", () => {
expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify");
});
it("getSongUrl returns the spotify: sentinel, not a real URL", async () => {
const p = new SpotifyProvider(fakeApi());
const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh");
expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" });
});
it("search delegates to the web API", async () => {
const api = fakeApi({
search: vi.fn().mockResolvedValue({
songs: [{ id: "t1", platform: "spotify" }],
playlists: [],
albums: [],
}),
});
const out = await new SpotifyProvider(api).search("queen", 5);
expect(api.search).toHaveBeenCalledWith("queen", 5);
expect(out.songs[0].id).toBe("t1");
});
it("getAuthStatus reflects credential presence", async () => {
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true);
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false);
});
it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => {
const api = fakeApi({
getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]),
getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]),
});
const p = new SpotifyProvider(api);
expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p");
expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a");
});
it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => {
const p = new SpotifyProvider(fakeApi());
expect(await p.getLyrics("x")).toEqual([]);
expect(await p.getRecommendPlaylists()).toEqual([]);
expect((await p.getQrCode()).key).toBe("");
expect(await p.checkQrCodeStatus("k")).toBe("expired");
});
});
+86
View File
@@ -0,0 +1,86 @@
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
Album,
SearchResult,
LyricLine,
QrCodeResult,
AuthStatus,
} from "../provider.js";
import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js";
export class SpotifyProvider implements MusicProvider {
readonly platform = "spotify" as const;
private api: SpotifyWebApi;
private creds: SpotifyCreds = { clientId: "", clientSecret: "" };
private quality = "320";
constructor(api?: SpotifyWebApi) {
this.api = api ?? new SpotifyWebApi(() => this.creds);
}
setCreds(clientId: string, clientSecret: string): void {
this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" };
this.api.setCreds(this.creds);
}
async search(query: string, limit = 20): Promise<SearchResult> {
return this.api.search(query, limit);
}
// Stage 1: return a sentinel URI. The play path recognizes `spotify:` and
// skips with a "not playable yet" message; real audio arrives in Stage 2/3.
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
return { url: `spotify:track:${songId}` };
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
return this.api.getTrack(songId);
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
return this.api.getPlaylistTracks(playlistId);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
return this.api.getAlbumTracks(albumId);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return this.api.hasCreds()
? { loggedIn: true, nickname: "Spotify" }
: { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" };
}
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import {
mapSpotifyTrack,
mapSpotifyTracks,
mapSpotifyAlbum,
mapSpotifyPlaylist,
isSpotifyUri,
SpotifyWebApi,
} from "./webapi.js";
describe("mapSpotifyTrack", () => {
// Shape trimmed from GET /v1/search?type=track.
const raw = {
id: "4iV5W9uYEdYUVa79Axb7Rh",
name: "Bohemian Rhapsody",
artists: [{ name: "Queen" }],
album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] },
duration_ms: 354320,
};
it("maps a track to a Song with platform 'spotify' and seconds duration", () => {
const s = mapSpotifyTrack(raw);
expect(s.platform).toBe("spotify");
expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh");
expect(s.name).toBe("Bohemian Rhapsody");
expect(s.artist).toBe("Queen");
expect(s.album).toBe("A Night at the Opera");
expect(s.duration).toBe(354); // 354320ms → 354s
expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg");
});
it("joins multiple artists with ', '", () => {
const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] });
expect(s.artist).toBe("A, B");
});
it("tolerates missing fields", () => {
const s = mapSpotifyTrack({});
expect(s.id).toBe("");
expect(s.name).toBe("Unknown");
expect(s.artist).toBe("");
expect(s.duration).toBe(0);
expect(s.coverUrl).toBe("");
expect(s.platform).toBe("spotify");
});
it("mapSpotifyTracks returns [] for non-array input", () => {
expect(mapSpotifyTracks(undefined as any)).toEqual([]);
});
});
describe("mapSpotifyAlbum", () => {
it("maps an album with total_tracks → songCount", () => {
const a = mapSpotifyAlbum({
id: "1abc",
name: "A Night at the Opera",
artists: [{ name: "Queen" }],
images: [{ url: "https://i.scdn.co/a.jpg" }],
total_tracks: 12,
});
expect(a).toEqual({
id: "1abc",
name: "A Night at the Opera",
artist: "Queen",
coverUrl: "https://i.scdn.co/a.jpg",
songCount: 12,
platform: "spotify",
});
});
});
describe("mapSpotifyPlaylist", () => {
it("maps a playlist with tracks.total → songCount", () => {
const p = mapSpotifyPlaylist({
id: "37i9",
name: "Today's Top Hits",
images: [{ url: "https://i.scdn.co/p.jpg" }],
tracks: { total: 50 },
});
expect(p).toEqual({
id: "37i9",
name: "Today's Top Hits",
coverUrl: "https://i.scdn.co/p.jpg",
songCount: 50,
platform: "spotify",
});
});
});
describe("isSpotifyUri", () => {
it("recognizes the sentinel URI", () => {
expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true);
expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false);
expect(isSpotifyUri("")).toBe(false);
});
});
describe("SpotifyWebApi rate-limit handling", () => {
it("retries once on 429 (honoring Retry-After) then returns data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success
expect(out.songs[0].id).toBe("t1");
});
it("returns empty results when unconfigured (no creds → no token)", async () => {
const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" }));
expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] });
});
});
+184
View File
@@ -0,0 +1,184 @@
import axios, { type AxiosInstance } from "axios";
import type { Song, Album, Playlist, SearchResult } from "../provider.js";
export interface SpotifyCreds {
clientId: string;
clientSecret: string;
}
const ACCOUNTS_BASE = "https://accounts.spotify.com";
const API_BASE = "https://api.spotify.com";
function artistsToString(artists: unknown): string {
return Array.isArray(artists)
? artists.map((a: any) => a?.name).filter(Boolean).join(", ")
: "";
}
/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */
export function mapSpotifyTrack(raw: any): Song {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
album: raw?.album?.name ?? "",
duration: Math.round((raw?.duration_ms ?? 0) / 1000),
coverUrl: raw?.album?.images?.[0]?.url ?? "",
platform: "spotify",
};
}
export function mapSpotifyTracks(raw: any): Song[] {
return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : [];
}
export function mapSpotifyAlbum(raw: any): Album {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.total_tracks ?? 0,
platform: "spotify",
};
}
export function mapSpotifyPlaylist(raw: any): Playlist {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.tracks?.total ?? 0,
platform: "spotify",
};
}
/** True for the getSongUrl sentinel (spotify:track:<id>); real audio lands in Stage 2/3. */
export function isSpotifyUri(url: string): boolean {
return typeof url === "string" && url.startsWith("spotify:");
}
export class SpotifyWebApi {
private getCreds: () => SpotifyCreds;
private http: AxiosInstance;
private auth: AxiosInstance;
private token = "";
private tokenExpiresAt = 0;
constructor(
getCreds: () => SpotifyCreds,
deps?: { http?: AxiosInstance; auth?: AxiosInstance }
) {
this.getCreds = getCreds;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
}
setCreds(_c: SpotifyCreds): void {
// Creds are read live via getCreds(); force a token refresh on next call.
this.token = "";
this.tokenExpiresAt = 0;
}
hasCreds(): boolean {
const c = this.getCreds();
return !!c.clientId && !!c.clientSecret;
}
/** Client-Credentials app token, cached until ~30s before expiry. */
private async getToken(): Promise<string | null> {
if (!this.hasCreds()) return null;
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
const { clientId, clientSecret } = this.getCreds();
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
try {
const { data } = await this.auth.post(
"/api/token",
"grant_type=client_credentials",
{
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
this.token = data?.access_token ?? "";
this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000;
return this.token || null;
} catch {
return null;
}
}
private async get(
path: string,
params?: Record<string, unknown>,
retryOn429 = true
): Promise<any | null> {
const token = await this.getToken();
if (!token) return null;
try {
const { data } = await this.http.get(path, {
params,
headers: { Authorization: `Bearer ${token}` },
});
return data;
} catch (err: any) {
// Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds).
// Retry once after the advised delay before giving up.
if (retryOn429 && err?.response?.status === 429) {
const retryAfter = Number(err.response.headers?.["retry-after"] ?? 1);
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000));
return this.get(path, params, false);
}
return null;
}
}
async search(query: string, limit = 20): Promise<SearchResult> {
const data = await this.get("/v1/search", {
q: query,
type: "track,album,playlist",
limit,
});
if (!data) return { songs: [], playlists: [], albums: [] };
return {
songs: mapSpotifyTracks(data?.tracks?.items),
albums: Array.isArray(data?.albums?.items)
? data.albums.items.filter(Boolean).map(mapSpotifyAlbum)
: [],
playlists: Array.isArray(data?.playlists?.items)
? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist)
: [],
};
}
async getTrack(id: string): Promise<Song | null> {
const data = await this.get(`/v1/tracks/${id}`);
return data ? mapSpotifyTrack(data) : null;
}
async getAlbumTracks(albumId: string): Promise<Song[]> {
// Album-track objects omit the album block; fetch the album cover once and inject it.
const album = await this.get(`/v1/albums/${albumId}`);
const cover = album?.images?.[0]?.url ?? "";
const albumName = album?.name ?? "";
const items = album?.tracks?.items;
if (!Array.isArray(items)) return [];
return items.filter(Boolean).map((t: any) => ({
...mapSpotifyTrack(t),
album: albumName,
coverUrl: cover,
}));
}
async getPlaylistTracks(playlistId: string): Promise<Song[]> {
const data = await this.get(`/v1/playlists/${playlistId}/tracks`, { limit: 100 });
const items = data?.items;
if (!Array.isArray(items)) return [];
return items
.map((it: any) => it?.track)
.filter((t: any) => t && t.id)
.map(mapSpotifyTrack);
}
}
+7 -1
View File
@@ -11,7 +11,9 @@ export function createAuthRouter(
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
cookieStore?: CookieStore cookieStore?: CookieStore,
kugouProvider?: MusicProvider,
spotifyProvider?: 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 +23,8 @@ 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;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -65,6 +69,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 +142,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);
+21 -2
View File
@@ -13,7 +13,9 @@ export function createMusicRouter(
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger, logger: Logger,
localProvider?: MusicProvider, localProvider?: MusicProvider,
config?: BotConfig config?: BotConfig,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider(); const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -26,6 +28,8 @@ 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;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -111,11 +115,17 @@ 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([ // Spotify is intentionally EXCLUDED from unified /search/all in Stage 1:
// its tracks are metadata-only (not yet playable) until the librespot audio
// backend lands (Stage 2/3), so surfacing them in the default all-sources
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
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 +133,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 +297,8 @@ 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",
spotify: spotifyProvider?.getQuality() ?? "320",
}); });
}); });
@@ -305,6 +318,12 @@ 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);
}
if ((!platform || platform === "spotify") && spotifyProvider) {
spotifyProvider.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"
); );
+4 -2
View File
@@ -39,6 +39,8 @@ export interface WebServerOptions {
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
localProvider: MusicProvider; localProvider: MusicProvider;
kugouProvider: MusicProvider;
spotifyProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string; configPath: string;
@@ -124,7 +126,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, options.spotifyProvider)
); );
app.use("/api/player", createPlayerRouter( app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database, options.botManager, logger, options.database,
@@ -132,7 +134,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, options.spotifyProvider)
); );
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
+22 -10
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> = {
@@ -118,17 +133,14 @@ let mobileRaf: number | null = null;
function updateMobileProgress() { function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0; const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0 mobileProgressPct.value = duration > 0
? Math.min((playerStore.elapsed / duration) * 100, 100) ? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0; : 0;
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;
+20 -7
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';
@@ -135,8 +140,9 @@ function formatTime(seconds: number): string {
} }
function updateProgress() { function updateProgress() {
// Use store.elapsed which interpolates from server ground truth // liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
currentElapsed.value = store.elapsed; // from the server anchor on every frame so the clock ticks each second (#107).
currentElapsed.value = store.liveElapsed();
const duration = currentSong.value?.duration ?? 0; const duration = currentSong.value?.duration ?? 0;
progressPercent.value = duration > 0 progressPercent.value = duration > 0
@@ -184,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> = {
+12 -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' : song.platform === 'spotify' ? 'badge-spotify' : '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' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -140,6 +140,16 @@ function formatDuration(seconds: number): string {
color: var(--color-primary); color: var(--color-primary);
} }
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.badge-spotify {
background: var(--brand-spotify-12);
color: var(--brand-spotify);
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+2
View File
@@ -24,6 +24,8 @@ import type { Source } from '../stores/player.js';
const LABELS: Record<Source, string> = { const LABELS: Record<Source, string> = {
netease: '网易云', netease: '网易云',
qq: 'QQ', qq: 'QQ',
kugou: '酷狗',
spotify: 'Spotify',
}; };
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 };
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { interpolateElapsed, type TimingState } from "./player.js";
afterEach(() => {
vi.restoreAllMocks();
});
function timing(partial: Partial<TimingState>): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, ...partial };
}
describe("interpolateElapsed", () => {
it("returns serverElapsed before playback has a sync anchor", () => {
expect(interpolateElapsed(timing({ serverElapsed: 12, wasPlaying: false }), false, Infinity)).toBe(12);
// wasPlaying but no sync time yet
expect(interpolateElapsed(timing({ serverElapsed: 5, wasPlaying: true, serverSyncTime: 0 }), false, Infinity)).toBe(5);
});
it("advances with wall-clock time while playing (regression: must not be frozen)", () => {
const spy = vi.spyOn(Date, "now");
const t = timing({ serverElapsed: 30, serverSyncTime: 10_000, wasPlaying: true });
spy.mockReturnValue(10_000);
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(30, 5);
spy.mockReturnValue(11_000); // +1s
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(31, 5);
spy.mockReturnValue(13_500); // +3.5s — distinct from the 1s reading
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(33.5, 5);
});
it("freezes at serverElapsed while paused", () => {
vi.spyOn(Date, "now").mockReturnValue(99_000);
const t = timing({ serverElapsed: 42, serverSyncTime: 10_000, wasPlaying: true });
expect(interpolateElapsed(t, true, Infinity)).toBe(42);
});
it("clamps to maxDuration", () => {
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
const t = timing({ serverElapsed: 100, serverSyncTime: 1_000, wasPlaying: true });
expect(interpolateElapsed(t, false, 180)).toBe(180);
});
});
+98 -17
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' | 'spotify';
} }
export type Source = 'netease' | 'qq'; export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
export interface BotStatus { export interface BotStatus {
id: string; id: string;
@@ -47,7 +47,7 @@ export interface FavoritePlaylist {
createdAt: string; createdAt: string;
} }
interface TimingState { export interface TimingState {
serverElapsed: number; serverElapsed: number;
serverSyncTime: number; serverSyncTime: number;
wasPlaying: boolean; wasPlaying: boolean;
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false }; return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
} }
/**
* Interpolate the live elapsed seconds from the last server anchor.
*
* This is a PURE function (its only time source is `Date.now()`), deliberately
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
* The `elapsed` getter is a Vue `computed` and caches its result until a
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
* the getter from a requestAnimationFrame loop therefore returns a frozen value
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
* must call this helper (via the `liveElapsed` action) instead.
*/
export function interpolateElapsed(
timing: TimingState,
isPaused: boolean,
maxDuration: number,
): number {
// No live anchor yet, or paused: report the frozen server position.
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
return Math.min(timing.serverElapsed, maxDuration);
}
return Math.min(
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
maxDuration,
);
}
export const usePlayerStore = defineStore('player', { export const usePlayerStore = defineStore('player', {
state: () => ({ state: () => ({
bots: [] as BotStatus[], bots: [] as BotStatus[],
@@ -73,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[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[] }, dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] }, userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false }, authStatus: { netease: false, qq: false, kugou: false, spotify: false },
lastFetchTime: 0, lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user) // Favorited playlists (fetched from server, isolated per WebUI user)
@@ -111,26 +138,47 @@ export const usePlayerStore = defineStore('player', {
if (!botId) return []; if (!botId) return [];
return this.queues[botId] ?? []; return this.queues[botId] ?? [];
}, },
/** Interpolated elapsed for the active bot */ /**
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
* a Vue `computed` and is CACHED — it only re-runs when a reactive
* dependency changes, so it does NOT tick every second on its own. Use it
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
* must call the `liveElapsed` action so the clock advances smoothly (#107).
*/
elapsed(): number { elapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id; const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0; if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming(); const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity; const maxDuration = this.activeBot.currentSong.duration || Infinity;
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration); return interpolateElapsed(timing, this.isPaused, maxDuration);
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
}, },
/** Sources that are currently logged in. Order: netease before qq. */ /** Sources that are currently logged in. Order: netease before qq. */
availableSources(): Source[] { availableSources(): Source[] {
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');
if (this.authStatus.spotify) s.push('spotify');
return s; return s;
}, },
}, },
actions: { actions: {
/**
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
* memoised — call it from requestAnimationFrame / interval loops so the
* progress bar and lyrics advance every frame instead of jumping on each
* server push (#107).
*/
liveElapsed(): number {
const botId = this.activeBotId ?? this.bots[0]?.id;
if (!botId || !this.activeBot?.currentSong) return 0;
const timing = this.timings[botId] ?? defaultTiming();
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
_getTiming(botId: string): TimingState { _getTiming(botId: string): TimingState {
if (!this.timings[botId]) { if (!this.timings[botId]) {
this.timings[botId] = defaultTiming(); this.timings[botId] = defaultTiming();
@@ -403,9 +451,10 @@ export const usePlayerStore = defineStore('player', {
async pause() { async pause() {
if (!this.activeBotId) return; if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value // Freeze elapsed at the current LIVE interpolated value. Using the cached
// `elapsed` getter here could snapshot a value up to a few seconds stale.
this._setTiming(this.activeBotId, { this._setTiming(this.activeBotId, {
serverElapsed: this.elapsed, serverElapsed: this.liveElapsed(),
wasPlaying: false, wasPlaying: false,
}); });
await axios.post(`/api/player/${this.activeBotId}/pause`); await axios.post(`/api/player/${this.activeBotId}/pause`);
@@ -524,18 +573,27 @@ 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, spAuthRes] = 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' } }),
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
]); ]);
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,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.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 ||
newAuth.spotify !== this.authStatus.spotify;
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;
this.authStatus.spotify = newAuth.spotify;
// 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.
@@ -574,15 +632,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 ?? []) : [];
@@ -596,6 +669,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') {
@@ -607,7 +686,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 === 'spotify' ? v : fallback;
} }
export function saveTabSource(key: TabKey, value: Source): void { export function saveTabSource(key: TabKey, value: Source): void {
+4
View File
@@ -80,6 +80,10 @@
--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);
--brand-spotify: #1DB954;
--brand-spotify-12: rgba(29, 185, 84, 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
+3 -1
View File
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
function syncLyrics() { function syncLyrics() {
if (!store.isPlaying || lines.value.length === 0) return; if (!store.isPlaying || lines.value.length === 0) return;
const elapsed = store.elapsed; // liveElapsed() (action) is recomputed now; the cached `elapsed` getter only
// refreshed on server pushes, leaving highlights ~half a line behind (#107).
const elapsed = store.liveElapsed();
const idx = findActiveLine(elapsed); const idx = findActiveLine(elapsed);
// Only update when the active line actually changes // Only update when the active line actually changes
if (idx !== activeLine.value && idx >= 0) { if (idx !== activeLine.value && idx >= 0) {
+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 {