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
TIANYAO ZHANG ea6820204d Merge pull request #108 from Fa1nttt/feature/local-audio-upload
feat: add local audio upload playback 增加本地音频上传播放功能
2026-06-30 16:03:32 +08:00
saopig1andClaude Opus 4.8 e849db2286 fix(local-audio): reference-aware cleanup, upload quota, stricter validation
Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.

Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
  empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
  client Content-Type); the stored extension is always a known audio type

Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.

Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:58:22 +08:00
Fa1nttt e12cbf8863 feat: add local audio upload playback 2026-06-30 14:08:42 +08:00
45 changed files with 5291 additions and 451 deletions

No files matched your search

+27 -12
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" />
@@ -26,19 +27,20 @@
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/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 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲 - **播放历史** — 自动记录所有播放过的歌曲
@@ -299,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 文字命令
@@ -321,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>` / 链接) |
@@ -337,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` | 投票跳过当前歌曲 |
@@ -406,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 服务(自动启动)
@@ -456,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 |
@@ -572,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
@@ -629,10 +641,11 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
### 最新版本 ### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM** **功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。 - **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。 - **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。 - **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。 - **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。 - **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
@@ -725,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 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢 ## 致谢
@@ -745,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"; 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);
});
});
+97 -3
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 = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -35,6 +36,9 @@ export interface BotInstanceOptions {
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider; youtubeProvider: MusicProvider;
localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -67,6 +71,9 @@ export class BotInstance extends EventEmitter {
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider; private youtubeProvider: 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;
@@ -95,6 +102,9 @@ export class BotInstance extends EventEmitter {
this.qqProvider = options.qqProvider; this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider; this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider; this.youtubeProvider = options.youtubeProvider;
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 });
@@ -147,6 +157,41 @@ export class BotInstance extends EventEmitter {
}); });
} }
isLocalAudioEnabled(): boolean {
return this.config.localAudioEnabled !== false;
}
/**
* Reference-aware cleanup of uploaded local audio files. Delegates to the
* local provider, which deletes a file only when it has been played AND is
* no longer referenced by ANY bot's queue — so loop replays, prev, the song
* being re-started, and the same upload queued on another bot are all safe.
* Call this AFTER the queue mutation, so released songs are unreferenced
* (and deleted) while songs that remain queued are preserved.
*/
cleanupQueuedLocalSongs(reason: string): void {
this.sweepLocalAudio(reason);
}
private sweepLocalAudio(reason: string): void {
const provider = this.localProvider as MusicProvider & {
sweepUnreferenced?: () => string[];
};
if (typeof provider.sweepUnreferenced !== "function") return;
try {
const deleted = provider.sweepUnreferenced();
if (deleted.length) {
this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files");
}
} catch (err) {
this.logger.warn({ err, reason }, "Local audio cleanup failed");
}
}
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
return !!a && !!b && a.platform === b.platform && a.id === b.id;
}
private setupTsEvents(): void { private setupTsEvents(): void {
this.tsClient.on("textMessage", (msg: TS3TextMessage) => { this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
this.handleTextMessage(msg).catch((err) => { this.handleTextMessage(msg).catch((err) => {
@@ -161,6 +206,8 @@ export class BotInstance extends EventEmitter {
// short-circuited on !this.connected, leaving player stuck as "playing". // short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false; this.connected = false;
this.player.stop(); this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
// A lifecycle change must not leave a stale auto-resume armed. // A lifecycle change must not leave a stale auto-resume armed.
this.autoPaused = false; this.autoPaused = false;
// Only emit externally once per lifecycle so clients don't see a // Only emit externally once per lifecycle so clients don't see a
@@ -242,6 +289,8 @@ export class BotInstance extends EventEmitter {
disconnect(): void { disconnect(): void {
this._cancelIdleTimer(); this._cancelIdleTimer();
this.player.stop(); this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
this.connected = false; this.connected = false;
if (!this.disconnectEmitted) { if (!this.disconnectEmitted) {
this.disconnectEmitted = true; this.disconnectEmitted = true;
@@ -482,9 +531,12 @@ export class BotInstance extends EventEmitter {
} }
} }
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): 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 === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
@@ -497,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;
} }
@@ -506,6 +560,10 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping"); this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
return false; return false;
} }
if (song.platform === "local" && !this.isLocalAudioEnabled()) {
this.logger.warn({ songId: song.id, name: song.name }, "Local audio playback disabled — refusing track");
return false;
}
// Clear any accumulated skip votes — every fresh track starts with a // Clear any accumulated skip votes — every fresh track starts with a
// clean slate, regardless of which code path loaded it (cmdPlay, // clean slate, regardless of which code path loaded it (cmdPlay,
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.). // cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
@@ -529,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;
@@ -602,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}`;
@@ -621,6 +690,10 @@ export class BotInstance extends EventEmitter {
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
const song0 = song!; const song0 = song!;
const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) {
this.player.stop();
}
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
this.queue.add({ ...song0 }); this.queue.add({ ...song0 });
@@ -629,6 +702,10 @@ export class BotInstance extends EventEmitter {
// Reset failure counter on user-initiated play // Reset failure counter on user-initiated play
this.player.resetFailures(); this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
// longer referenced (and get deleted), but song0 — if it is the same local
// upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`; if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`; return `Now playing: ${song0.name} - ${song0.artist}`;
} }
@@ -708,6 +785,7 @@ export class BotInstance extends EventEmitter {
this.player.stop(); this.player.stop();
this.autoPaused = false; this.autoPaused = false;
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("stopped");
this.disableFmMode(); this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => { this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop"); this.logger.warn({ err }, "Profile restore failed on stop");
@@ -766,6 +844,7 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string { private cmdClear(): string {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("queue_cleared");
this.disableFmMode(); this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => { this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear"); this.logger.warn({ err }, "Profile restore failed on clear");
@@ -779,6 +858,9 @@ export class BotInstance extends EventEmitter {
if (isNaN(index) || index < 0) return "Usage: !remove <number>"; if (isNaN(index) || index < 0) return "Usage: !remove <number>";
const removed = this.queue.remove(index); const removed = this.queue.remove(index);
if (!removed) return "Invalid position"; if (!removed) return "Invalid position";
// Sweep after the entry is gone — the file is deleted only if no other
// queue position (or bot) still references this upload.
this.sweepLocalAudio("removed_from_queue");
this.emit("stateChange"); this.emit("stateChange");
return `Removed: ${removed.name}`; return `Removed: ${removed.name}`;
} }
@@ -838,6 +920,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPlaylistSongs(playlistId); const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found"; if (songs.length === 0) return "Playlist is empty or not found";
this.player.stop();
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
@@ -845,6 +928,7 @@ export class BotInstance extends EventEmitter {
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -873,6 +957,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getAlbumSongs(albumId); const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found"; if (songs.length === 0) return "Album is empty or not found";
this.player.stop();
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
@@ -880,6 +965,7 @@ export class BotInstance extends EventEmitter {
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -902,6 +988,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) if (songs.length === 0)
return "No FM songs available (need to login first)"; return "No FM songs available (need to login first)";
this.player.stop();
this.queue.clear(); this.queue.clear();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
@@ -913,6 +1000,7 @@ export class BotInstance extends EventEmitter {
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM"; const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
@@ -935,6 +1023,7 @@ export class BotInstance extends EventEmitter {
filtered = result.songs.slice(0, 20); filtered = result.songs.slice(0, 20);
} }
this.player.stop();
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of filtered) { for (const song of filtered) {
@@ -945,6 +1034,7 @@ export class BotInstance extends EventEmitter {
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.sweepLocalAudio("queue_replaced");
this.emit("stateChange"); this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`; return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
} }
@@ -1050,10 +1140,10 @@ export class BotInstance extends EventEmitter {
async playNext(maxRetries = 3): Promise<boolean> { async playNext(maxRetries = 3): Promise<boolean> {
if (this.isAdvancing || !this.connected) return false; if (this.isAdvancing || !this.connected) return false;
this.isAdvancing = true; this.isAdvancing = true;
let started = false;
try { try {
this.voteSkipUsers.clear(); this.voteSkipUsers.clear();
const next = this.queue.next(); const next = this.queue.next();
let started = false;
if (next) { if (next) {
started = await this.resolveAndPlay(next); started = await this.resolveAndPlay(next);
if (!started) { if (!started) {
@@ -1093,6 +1183,10 @@ export class BotInstance extends EventEmitter {
this.emit("stateChange"); this.emit("stateChange");
return started; return started;
} finally { } finally {
// Reference-aware sweep: a finished local song that still sits in the
// queue (sequential history, loop/repeat, or queued on another bot) is
// preserved; only uploads no longer referenced anywhere are deleted.
this.sweepLocalAudio("playback_finished");
this.isAdvancing = false; this.isAdvancing = false;
} }
} }
+37 -1
View File
@@ -74,6 +74,9 @@ export class BotManager extends EventEmitter {
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider; private youtubeProvider: 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;
@@ -90,13 +93,25 @@ export class BotManager extends EventEmitter {
logger: Logger, logger: Logger,
avatarStore: AvatarStore, avatarStore: AvatarStore,
permissions: PermissionStore, permissions: PermissionStore,
configPath: string configPath: string,
localProvider?: MusicProvider,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider; this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider; this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider(); this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
setInUseResolver: (resolver: () => Set<string>) => void;
}>;
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
this.database = database; this.database = database;
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
@@ -127,6 +142,9 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
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,
@@ -210,6 +228,18 @@ export class BotManager extends EventEmitter {
return Array.from(this.bots.values()); return Array.from(this.bots.values());
} }
/** Local upload ids still referenced by any bot's queue. The local provider
* uses this to avoid deleting a file another queue/bot is still using. */
getReferencedLocalSongIds(): Set<string> {
const ids = new Set<string>();
for (const bot of this.bots.values()) {
for (const song of bot.getQueueManager().list()) {
if (song.platform === "local") ids.add(song.id);
}
}
return ids;
}
async startBot(id: string): Promise<void> { async startBot(id: string): Promise<void> {
const oldBot = this.bots.get(id); const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`); if (!oldBot) throw new Error(`Bot ${id} not found`);
@@ -253,6 +283,9 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
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,
@@ -305,6 +338,9 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
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,
});
});
});
+42
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";
@@ -22,6 +31,8 @@ export interface BotConfig {
autoReturnDelay: number; autoReturnDelay: number;
autoPauseOnEmpty: boolean; autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number; idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接). // Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example: // Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000" // "https://music.example.com" or "http://1.2.3.4:3000"
@@ -31,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 {
@@ -50,6 +62,7 @@ export function getDefaultConfig(): BotConfig {
// clients are present). Users can opt in from the web UI. // clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false, autoPauseOnEmpty: false,
idleTimeoutMinutes: 0, idleTimeoutMinutes: 0,
localAudioEnabled: true,
publicUrl: "", publicUrl: "",
trustProxy: false, trustProxy: false,
guestMode: { guestMode: {
@@ -66,6 +79,14 @@ export function getDefaultConfig(): BotConfig {
playCollection: false, playCollection: false,
}, },
}, },
spotify: {
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
},
}; };
} }
@@ -115,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"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
coverUrl: string; coverUrl: string;
} }
+23 -1
View File
@@ -7,6 +7,9 @@ import { createApiServerManager } from "./music/api-server.js";
import { NeteaseProvider } from "./music/netease.js"; 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 { 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";
@@ -25,6 +28,7 @@ const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs"); const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies"); const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars"); const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist"); const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() { async function main() {
@@ -54,6 +58,16 @@ async function main() {
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl()); const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
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 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);
@@ -63,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);
@@ -75,7 +91,10 @@ async function main() {
logger, logger,
avatarStore, avatarStore,
permissions, permissions,
CONFIG_PATH CONFIG_PATH,
localProvider,
kugouProvider,
spotifyProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -85,6 +104,9 @@ async function main() {
neteaseProvider, neteaseProvider,
qqProvider, qqProvider,
bilibiliProvider, bilibiliProvider,
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
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Seed real files + an index.json so we can exercise the cleanup lifecycle
// without invoking the ffmpeg duration probe that uploadAudio runs.
function makeRecord(id: string, bytes = 16) {
const filePath = join(dir, `${id}.mp3`);
writeFileSync(filePath, Buffer.alloc(bytes, 1));
return {
id,
name: id,
artist: "本地上传",
album: "本地音乐",
duration: 0,
coverUrl: "",
platform: "local" as const,
filePath,
originalName: `${id}.mp3`,
uploadedAt: "1970-01-01T00:00:00.000Z",
size: bytes,
mimeType: "audio/mpeg",
};
}
function seed(records: ReturnType<typeof makeRecord>[]) {
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
}
describe("LocalMusicProvider cleanup lifecycle", () => {
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
const c = makeRecord("c");
seed([a, b, c]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played, but referenced
await p.getSongUrl("b"); // played and unreferenced
// "c" was never played (e.g. uploaded but not queued)
const deleted = p.sweepUnreferenced();
expect(deleted).toEqual(["b"]);
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
expect(existsSync(c.filePath)).toBe(true); // never played → kept
});
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // loop queue still references it
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // first pass plays it
p.sweepUnreferenced(); // "playback_finished" sweep
expect(existsSync(a.filePath)).toBe(true);
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
});
it("re-playing a queued local song does not delete it (play-song order)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
// Mirror the fixed endpoint order: the song is (re)added to the queue
// BEFORE the sweep runs, so it is referenced when we sweep.
const refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played once
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
expect(existsSync(a.filePath)).toBe(true);
expect(await p.getSongUrl("a")).not.toBeNull();
});
it("deletes a played file once it leaves every queue", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
let refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a");
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(true); // still queued
refs = new Set<string>(); // queue cleared
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(false); // now removed
expect(await p.getSongUrl("a")).toBeNull();
});
it("never deletes anything when the reference resolver throws", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
p.setInUseResolver(() => {
throw new Error("manager unavailable");
});
await p.getSongUrl("a");
expect(p.sweepUnreferenced()).toEqual([]);
expect(existsSync(a.filePath)).toBe(true);
});
});
describe("LocalMusicProvider upload validation", () => {
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("malicious"),
originalName: "evil.exe",
mimeType: "application/octet-stream",
}),
).rejects.toThrow();
});
it("rejects an unknown extension even when the mime claims audio", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("x"),
originalName: "evil.html",
mimeType: "audio/mpeg",
}),
).rejects.toThrow();
});
it("rejects an empty file", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
});
describe("LocalMusicProvider quota", () => {
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]); // newest-first: b newer than a
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
p.setInUseResolver(() => new Set<string>());
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
expect(existsSync(b.filePath)).toBe(true);
const result = await p.search("");
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
});
it("does not evict a referenced upload even when over the cap", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
});
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
// The returned song must actually exist and be playable — not a phantom.
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
const longName = "x".repeat(300) + ".mp3";
// Must not throw the "unsupported format" error — the extension survives.
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 1),
originalName: longName,
mimeType: "audio/mpeg",
});
expect(song.id).toBeTruthy();
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
+391
View File
@@ -0,0 +1,391 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
import type {
Album,
AuthStatus,
LyricLine,
MusicProvider,
Playlist,
PlaylistDetail,
QrCodeResult,
SearchResult,
Song,
SongUrlResult,
} from "./provider.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
const AUDIO_EXTENSIONS = new Set([
".mp3",
".flac",
".wav",
".m4a",
".aac",
".ogg",
".opus",
".webm",
".wma",
".alac",
".aiff",
".ape",
]);
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
export interface LocalMusicProviderOptions {
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
maxFiles?: number;
/** Max total bytes of uploaded files kept on disk. */
maxTotalBytes?: number;
}
interface LocalSongRecord extends Song {
filePath: string;
originalName: string;
uploadedAt: string;
size: number;
mimeType: string;
}
function safeFileName(name: string): string {
const base = path.basename(name || "audio")
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, " ")
.trim();
if (!base) return "audio";
// Cap the total length but ALWAYS preserve the extension — truncating the
// whole string would drop a trailing ".mp3" on a long filename and make the
// file fail extension validation.
const ext = path.extname(base);
const stem = ext ? base.slice(0, base.length - ext.length) : base;
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
return `${safeStem}${ext}`;
}
function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
async function probeDurationSeconds(filePath: string): Promise<number> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
});
}
export class LocalMusicProvider implements MusicProvider {
readonly platform = "local" as const;
private readonly uploadDir: string;
private readonly indexPath: string;
private records: LocalSongRecord[] = [];
private readonly maxFiles: number;
private readonly maxTotalBytes: number;
/** Ids that have been resolved for playback at least once; only these are
* eligible for reference-aware cleanup, so freshly uploaded files that are
* not yet queued/played survive in the search list. */
private playedIds = new Set<string>();
/** Returns the set of local song ids still referenced by any bot's queue.
* Deletion never removes a file whose id this set contains. */
private inUseResolver: () => Set<string> = () => new Set<string>();
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
* ffmpeg on Windows still releasing a just-stopped track). */
private retrying = new Set<string>();
constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
this.uploadDir = uploadDir;
this.indexPath = path.join(uploadDir, "index.json");
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
mkdirSync(uploadDir, { recursive: true });
this.loadIndex();
}
/** Wire the resolver the BotManager uses to report which uploads are still
* queued anywhere. Must be set before any cleanup can delete files. */
setInUseResolver(resolver: () => Set<string>): void {
this.inUseResolver = resolver;
}
private referencedIds(): Set<string> | null {
try {
return this.inUseResolver() ?? new Set<string>();
} catch {
// Resolver failure → references unknown → refuse to delete anything.
return null;
}
}
private loadIndex(): void {
try {
const raw = readFileSync(this.indexPath, "utf8");
const parsed = JSON.parse(raw) as LocalSongRecord[];
this.records = Array.isArray(parsed)
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
: [];
} catch {
this.records = [];
}
}
private saveIndex(): void {
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
}
async uploadAudio(input: {
buffer: Buffer;
originalName: string;
mimeType?: string;
}): Promise<Song> {
const originalName = safeFileName(input.originalName || "audio");
const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
mimeType: input.mimeType || "application/octet-stream",
};
this.records.unshift(song);
this.saveIndex();
// Never evict the file we just accepted, even if every older file is still
// queued — returning success for a file we deleted would be a phantom entry.
this.enforceQuota(id);
return this.toSong(song);
}
private toSong(record: LocalSongRecord): Song {
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
return song;
}
async search(query: string, limit = 20): Promise<SearchResult> {
const q = query.trim().toLowerCase();
const songs = this.records
.filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit)
.map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
const record = this.records.find((r) => r.id === songId);
if (!record || !existsSync(record.filePath)) return null;
// A song that is actually resolved for playback becomes eligible for
// cleanup once it is no longer referenced by any queue.
this.playedIds.add(songId);
return { url: record.filePath };
}
async getSongDetail(songId: string): Promise<Song | null> {
const record = this.records.find((r) => r.id === songId);
return record && existsSync(record.filePath) ? this.toSong(record) : null;
}
/**
* Reference-aware cleanup: delete only files that have been played at least
* once AND are no longer referenced by any bot's queue. Safe to call after
* any queue mutation — a file still queued anywhere (loop replay, prev,
* the song being re-started, the same upload queued on another bot) is kept.
* Returns the ids that were deleted.
*/
sweepUnreferenced(): string[] {
const inUse = this.referencedIds();
if (!inUse) return [];
const deleted: string[] = [];
for (let i = this.records.length - 1; i >= 0; i--) {
const r = this.records[i];
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
if (this.unlinkRecordAt(i)) {
deleted.push(r.id);
} else {
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
// record and retry shortly; never orphan it or abort the rest.
this.scheduleRetry(r.id);
}
}
if (deleted.length) this.saveIndex();
return deleted;
}
/** Evict oldest, never-referenced uploads until under the file-count and
* total-byte caps. Bounds disk use from uploads that are never played.
* `protectId` is never evicted (the file just uploaded in this same call). */
private enforceQuota(protectId?: string): void {
if (this.records.length <= this.maxFiles &&
this.totalBytes() <= this.maxTotalBytes) {
return;
}
const inUse = this.referencedIds();
if (!inUse) return; // can't safely evict without knowing references
let count = this.records.length;
let bytes = this.totalBytes();
let changed = false;
for (let i = this.records.length - 1;
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
i--) {
const r = this.records[i];
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
const size = r.size || 0;
if (this.unlinkRecordAt(i)) {
count--;
bytes -= size;
changed = true;
}
}
if (changed) this.saveIndex();
}
/**
* Delete the backing file for records[index] and drop the record from memory.
* Deletes the FILE FIRST, then mutates state only on success, so a failed
* unlink leaves the record intact (file + index stay consistent) instead of
* orphaning the file. Returns true if the file is gone (deleted or already
* absent), false if it is still present (locked). Never throws; does NOT
* persist the index — callers batch saveIndex().
*/
private unlinkRecordAt(index: number): boolean {
const r = this.records[index];
try {
rmSync(r.filePath, { force: true });
} catch {
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
// the file genuinely vanished anyway, fall through and drop the record.
if (existsSync(r.filePath)) return false;
}
this.records.splice(index, 1);
this.playedIds.delete(r.id);
this.retrying.delete(r.id);
return true;
}
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
* Uses unref'd timers so it never keeps the process alive. */
private scheduleRetry(id: string, attempt = 1): void {
if (attempt === 1 && this.retrying.has(id)) return;
this.retrying.add(id);
const MAX_ATTEMPTS = 6;
const timer = setTimeout(() => {
const index = this.records.findIndex((r) => r.id === id);
if (index < 0) { this.retrying.delete(id); return; } // already removed
const inUse = this.referencedIds();
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
if (this.unlinkRecordAt(index)) {
this.saveIndex();
} else if (attempt < MAX_ATTEMPTS) {
this.scheduleRetry(id, attempt + 1);
} else {
this.retrying.delete(id); // give up; next sweep/quota will retry
}
}, 500 * attempt);
if (typeof timer.unref === "function") timer.unref();
}
private totalBytes(): number {
return this.records.reduce((n, r) => n + (r.size || 0), 0);
}
setQuality(_quality: string): void {
// 本地文件按原始音质播放。
}
getQuality(): string {
return "original";
}
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
return [];
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
throw new Error("Local music does not require login");
}
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {
// no-op
}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "本地音乐" };
}
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
return null;
}
}
+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"; 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"; 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"; 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"; 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);
+5 -1
View File
@@ -36,6 +36,7 @@ export function createBotRouter(
res.json({ res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
}); });
@@ -44,7 +45,7 @@ export function createBotRouter(
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot // POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model) // behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => { router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body; const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined; const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) { if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -53,9 +54,11 @@ export function createBotRouter(
} }
const hasAutoPause = typeof autoPauseOnEmpty === "boolean"; const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
const hasLocalAudioEnabled = typeof localAudioEnabled === "boolean";
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes; if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object"; const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) { if (hasGuestMode) {
@@ -100,6 +103,7 @@ export function createBotRouter(
res.json({ res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
}); });
+93 -3
View File
@@ -1,25 +1,89 @@
import { Router } from "express"; import express, { Router } from "express";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js"; import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
export function createMusicRouter( export function createMusicRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
qqProvider: MusicProvider, qqProvider: MusicProvider,
bilibiliProvider: MusicProvider, bilibiliProvider: MusicProvider,
logger: Logger logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider(); const youtubeProvider: MusicProvider = new YouTubeProvider();
function isLocalAudioEnabled(): boolean {
return config?.localAudioEnabled !== false;
}
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 === "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;
} }
router.post(
"/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
(_req, res, next) => {
if (!isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
next();
},
express.raw({
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
async (req, res) => {
try {
if (!localProvider) {
res.status(501).json({ error: "Local upload is not configured" });
return;
}
const uploadCapable = localProvider as MusicProvider & {
uploadAudio?: (input: { buffer: Buffer; originalName: string; mimeType?: string }) => Promise<unknown>;
};
if (typeof uploadCapable.uploadAudio !== "function") {
res.status(501).json({ error: "Local upload is not supported" });
return;
}
if (!Buffer.isBuffer(req.body)) {
res.status(400).json({ error: "raw audio body is required" });
return;
}
const headerName = req.header("x-filename") || req.header("x-file-name") || "audio";
let originalName = headerName;
try {
originalName = decodeURIComponent(headerName);
} catch {
// Keep the raw header value if it is not URI encoded.
}
const song = await uploadCapable.uploadAudio({
buffer: req.body,
originalName,
mimeType: req.header("content-type") || undefined,
});
res.json({ song });
} catch (err) {
logger.warn({ err }, "Local audio upload failed");
res.status(400).json({ error: (err as Error).message });
}
},
);
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
try { try {
const { q, platform, limit } = req.query; const { q, platform, limit } = req.query;
@@ -27,6 +91,10 @@ export function createMusicRouter(
res.status(400).json({ error: "q (query) is required" }); res.status(400).json({ error: "q (query) is required" });
return; return;
} }
if (platform === "local" && !isLocalAudioEnabled()) {
res.json({ songs: [], playlists: [], albums: [] });
return;
}
const provider = getProvider(platform as string); const provider = getProvider(platform as string);
const result = await provider.search( const result = await provider.search(
q as string, q as string,
@@ -47,16 +115,25 @@ export function createMusicRouter(
return; return;
} }
const parsedLimit = parseInt(limit as string) || 20; const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult, bilibiliResult] = 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: [] }),
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
]); ]);
const songs = [ const songs = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(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 : []),
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
]; ];
const albums = [ const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
@@ -76,6 +153,10 @@ export function createMusicRouter(
router.get("/song/:id", async (req, res) => { router.get("/song/:id", async (req, res) => {
try { try {
if (req.query.platform === "local" && !isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
const song = await provider.getSongDetail(req.params.id); const song = await provider.getSongDetail(req.params.id);
if (!song) { if (!song) {
@@ -215,6 +296,9 @@ export function createMusicRouter(
netease: neteaseProvider.getQuality(), netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(), qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(), bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320",
}); });
}); });
@@ -234,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 });
}); });
+57 -4
View File
@@ -39,9 +39,20 @@ 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 "";
}; };
function isLocalAudioDisabled(bot: any, platform: unknown): boolean {
return platform === "local" &&
typeof bot.isLocalAudioEnabled === "function" &&
!bot.isLocalAudioEnabled();
}
function rejectDisabledLocalAudio(res: any): void {
res.status(403).json({ error: "本地音频播放已关闭" });
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => { router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
@@ -100,8 +111,12 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { platform } = req.body; const { platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -269,10 +284,14 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; const { playlistId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
// 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 === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -315,6 +334,10 @@ export function createPlayerRouter(
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
// leaves the previous queue — and its files — intact.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
// Use queue.play() for sequential, or pick random index for random modes // Use queue.play() for sequential, or pick random index for random modes
const mode = queue.getMode(); const mode = queue.getMode();
@@ -356,8 +379,12 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { albumId, platform } = req.body; const { albumId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
@@ -393,6 +420,8 @@ export function createPlayerRouter(
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
const mode = queue.getMode(); const mode = queue.getMode();
let first; let first;
@@ -432,13 +461,21 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear(); queue.clear();
queue.add(song); queue.add(song);
queue.play(); queue.play();
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
// Sweep AFTER the new song is queued+resolved, so replaying a local song
// that was still in the queue doesn't delete the file we're about to play.
bot.cleanupQueuedLocalSongs?.("queue_replaced");
if (!ok) { if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return; return;
@@ -460,6 +497,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the queue mutation + playback so concurrent requests can't // Serialize the queue mutation + playback so concurrent requests can't
// interleave (audible track must match queue.currentIndex). // interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => { const body = await bot.runExclusive(async () => {
@@ -504,6 +545,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the insert-after-current + promote + playback so concurrent // Serialize the insert-after-current + promote + playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex). // requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => { const body = await bot.runExclusive(async () => {
@@ -533,6 +578,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the queue mutation + (possible) playback so concurrent // Serialize the queue mutation + (possible) playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex). // requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => { const body = await bot.runExclusive(async () => {
@@ -561,8 +610,12 @@ export function createPlayerRouter(
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { songId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
? platform ? platform
: "netease" : "netease"
); );
+5 -2
View File
@@ -38,6 +38,9 @@ export interface WebServerOptions {
neteaseProvider: MusicProvider; neteaseProvider: MusicProvider;
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
localProvider: MusicProvider;
kugouProvider: MusicProvider;
spotifyProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string; configPath: string;
@@ -123,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) 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,
@@ -131,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> = {
+17 -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' : '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' : '网易云' }}</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>
@@ -135,6 +135,21 @@ function formatDuration(seconds: number): string {
color: var(--brand-youtube); color: var(--brand-youtube);
} }
.badge-local {
background: var(--color-primary-10);
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'; 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) {
+242 -7
View File
@@ -16,6 +16,41 @@
autofocus autofocus
/> />
</div> </div>
<div
v-if="localAudioEnabled"
class="local-upload"
:class="{ dragging: isDragging, uploading }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
</div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }}
</button>
<input
ref="fileInput"
class="file-input"
type="file"
multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
@change="handleFileSelect"
/>
</div>
<div v-else class="local-upload disabled">
<Icon icon="mdi:music-off" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">本地音频播放已关闭</div>
<div class="upload-subtitle">管理员可在「设置 → 行为设置 → 本地音频播放」中开启。</div>
</div>
</div>
<div v-if="uploadMessage" class="upload-message" :class="uploadMessageType">{{ uploadMessage }}</div>
</div> </div>
<div v-if="loading" class="loading">搜索中...</div> <div v-if="loading" class="loading">搜索中...</div>
@@ -37,6 +72,17 @@
: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
v-if="hasLocalSongs"
class="source-btn"
:class="{ active: selectedSource === 'local' }"
@click="selectedSource = 'local'"
>本地</button>
</div> </div>
<div class="tab-bar"> <div class="tab-bar">
@@ -48,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'" 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'"
@@ -56,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'" 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'"
@@ -141,17 +187,19 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source'; const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' { type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
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') 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';
} }
const query = ref((route.query.q as string) || ''); const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs'); const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource()); const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; } interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; } interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
@@ -161,6 +209,12 @@ const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
const uploading = ref(false);
const isDragging = ref(false);
const uploadMessage = ref('');
const uploadMessageType = ref<'info' | 'error'>('info');
const fileInput = ref<HTMLInputElement | null>(null);
const localAudioEnabled = ref(true);
const filteredSongs = computed(() => const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value) allSongs.value.filter((s) => s.platform === selectedSource.value)
@@ -174,14 +228,16 @@ const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value) allPlaylists.value.filter((p) => p.platform === selectedSource.value)
); );
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// Persist source preference // Persist source preference
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ } try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
}); });
// B站 has no albums/playlists — force songs tab when switching to B站 // B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') { if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
activeTab.value = 'songs'; activeTab.value = 'songs';
} }
}); });
@@ -223,10 +279,84 @@ async function doSearch() {
} }
} }
function isAudioFile(file: File): boolean {
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
}
async function uploadLocalFiles(fileList: File[]) {
if (!localAudioEnabled.value) {
uploadMessageType.value = 'error';
uploadMessage.value = '本地音频播放已关闭';
return;
}
const files = fileList.filter(isAudioFile);
if (files.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件';
return;
}
uploading.value = true;
uploadMessageType.value = 'info';
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = [];
const failed: string[] = [];
for (const file of files) {
try {
const res = await axios.post('/api/music/local/upload', file, {
headers: {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': encodeURIComponent(file.name),
},
maxBodyLength: Infinity,
});
if (res.data?.song) uploaded.push(res.data.song as Song);
} catch (err: any) {
failed.push(`${file.name}: ${err?.response?.data?.error || '上传失败'}`);
}
}
if (uploaded.length > 0) {
const uploadedKeys = new Set(uploaded.map((s) => `${s.platform}-${s.id}`));
allSongs.value = [
...uploaded,
...allSongs.value.filter((s) => !uploadedKeys.has(`${s.platform}-${s.id}`)),
];
selectedSource.value = 'local';
activeTab.value = 'songs';
searched.value = true;
uploadMessageType.value = failed.length ? 'error' : 'info';
uploadMessage.value = failed.length
? `已上传 ${uploaded.length} 个,失败 ${failed.length} 个:${failed[0]}`
: `已上传 ${uploaded.length} 个本地音频`;
} else {
uploadMessageType.value = 'error';
uploadMessage.value = failed[0] || '上传失败';
}
uploading.value = false;
}
function handleDrop(event: DragEvent) {
isDragging.value = false;
const files = Array.from(event.dataTransfer?.files ?? []);
uploadLocalFiles(files);
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
uploadLocalFiles(Array.from(input.files ?? []));
input.value = '';
}
function badgeLabel(platform: string): string { function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ'; if (platform === 'qq') return 'QQ';
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 === 'kugou') return '酷狗';
return '网易云'; return '网易云';
} }
@@ -234,10 +364,25 @@ function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq'; if (platform === 'qq') return 'badge-qq';
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 === 'kugou') return 'badge-kugou';
return 'badge-netease'; return 'badge-netease';
} }
async function loadLocalAudioSetting() {
try {
const res = await axios.get('/api/bot/settings');
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
if (!localAudioEnabled.value && selectedSource.value === 'local') {
selectedSource.value = 'netease';
}
} catch {
// Guests may not be allowed to read settings; backend still enforces the switch.
}
}
onMounted(() => { onMounted(() => {
loadLocalAudioSetting();
if (query.value) doSearch(); if (query.value) doSearch();
}); });
</script> </script>
@@ -258,6 +403,86 @@ onMounted(() => {
margin-bottom: 24px; margin-bottom: 24px;
} }
.local-upload {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
&.dragging {
border-color: var(--color-primary);
background: var(--color-primary-10);
transform: translateY(-1px);
}
&.uploading {
opacity: 0.8;
}
}
.upload-icon {
flex-shrink: 0;
font-size: 28px;
color: var(--color-primary);
}
.upload-copy {
flex: 1;
min-width: 0;
}
.upload-title {
font-size: 14px;
font-weight: var(--fw-semi);
color: var(--text-primary);
}
.upload-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.upload-btn {
flex-shrink: 0;
padding: 8px 14px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: var(--fw-semi);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
.file-input {
display: none;
}
.local-upload.disabled {
opacity: 0.65;
border-style: solid;
}
.upload-message {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
&.error {
color: #e74c3c;
}
}
.search-input-wrap { .search-input-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -422,6 +647,16 @@ onMounted(() => {
color: var(--brand-youtube); color: var(--brand-youtube);
} }
.badge-local {
background: var(--color-primary-10);
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;
+117 -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 -->
@@ -453,6 +523,19 @@
@change="saveAutoPause" @change="saveAutoPause"
/> />
</label> </label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">本地音频播放</div>
<div class="profile-toggle-hint">开启后允许在搜索页拖拽/选择本地音频上传并播放;关闭后会拒绝新的本地上传和本地歌曲播放请求。</div>
</div>
<input
v-model="localAudioEnabled"
type="checkbox"
class="profile-toggle-switch"
@change="saveLocalAudioEnabled"
/>
</label>
</section> </section>
<!-- Guest Mode (admin only) --> <!-- Guest Mode (admin only) -->
@@ -779,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
@@ -810,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 {
@@ -834,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
} }
@@ -859,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
@@ -876,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',
},
}); });
} }
@@ -1019,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 });
@@ -1037,12 +1134,14 @@ async function savePrefix() {
const idleTimeout = ref(0); const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig). // Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false); const autoPauseOnEmpty = ref(false);
const localAudioEnabled = ref(true);
async function loadIdleTimeout() { async function loadIdleTimeout() {
try { try {
const res = await axios.get('/api/bot/settings'); const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
applyGuestModeFromServer(res.data.guestMode); applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups); applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -1060,6 +1159,13 @@ async function saveAutoPause() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function saveLocalAudioEnabled() {
try {
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
localAudioEnabled.value = res.data.localAudioEnabled ?? localAudioEnabled.value;
} catch { /* ignore */ }
}
// --- Guest mode (admin only) --- // --- Guest mode (admin only) ---
const GUEST_FLAGS: { token: string; label: string }[] = [ const GUEST_FLAGS: { token: string; label: string }[] = [
{ token: 'addToQueue', label: '添加到队列末尾' }, { token: 'addToQueue', label: '添加到队列末尾' },
@@ -1535,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>
@@ -1676,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 {