mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-03 05:22:49 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d71fe418 | ||
|
|
50bdaae26a | ||
|
|
e1171dbcfe | ||
|
|
9a9f68c446 | ||
|
|
848b3931b8 | ||
|
|
828fd21e29 | ||
|
|
6c16e2d966 | ||
|
|
b352d71528 | ||
|
|
92bdfcb294 | ||
|
|
f8743a904f | ||
|
|
f6e42811a5 | ||
|
|
08260182a9 | ||
|
|
de4343cfbb | ||
|
|
af50d69b00 | ||
|
|
e1439a5899 | ||
|
|
5ae5168b6b | ||
|
|
4cb1da29d4 | ||
|
|
f9caea6c79 | ||
|
|
a12c419dd2 | ||
|
|
af326a37b7 | ||
|
|
45f5d236c1 |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">TSMusicBot</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,6 +15,7 @@
|
||||
<img src="https://img.shields.io/badge/许可证-MIT-green" />
|
||||
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
|
||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
@@ -29,17 +30,17 @@
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
|
||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||
- **播放历史** — 自动记录所有播放过的歌曲
|
||||
@@ -300,19 +301,19 @@ sudo systemctl start tsmusicbot
|
||||
- 端口(默认 9987,自托管或非标准端口请填写实际值)
|
||||
- 机器人昵称
|
||||
- 可选:服务器密码、默认频道
|
||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
|
||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
|
||||
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
||||
|
||||
### WebUI 页面说明
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
|
||||
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
|
||||
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
|
||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||
| **历史** | 播放历史记录 |
|
||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||
|
||||
### TeamSpeak 文字命令
|
||||
|
||||
@@ -322,9 +323,10 @@ sudo systemctl start tsmusicbot
|
||||
|------|------|
|
||||
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
|
||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||
| `!play -k <歌名>` | 从酷狗音乐搜索 |
|
||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
|
||||
| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||
@@ -338,9 +340,10 @@ sudo systemctl start tsmusicbot
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||
| `!album <ID>` | 加载专辑 |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
|
||||
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
||||
| `!lyrics` | 显示当前歌词 |
|
||||
| `!now` | 当前播放信息 |
|
||||
| `!vote` | 投票跳过当前歌曲 |
|
||||
@@ -407,6 +410,7 @@ teamspeak-music-bot/
|
||||
│ │ ├── netease.ts # 网易云音乐适配器
|
||||
│ │ ├── qq.ts # QQ 音乐适配器
|
||||
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
||||
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
|
||||
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
|
||||
│ │ ├── auth.ts # Cookie 持久化存储
|
||||
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
|
||||
@@ -457,8 +461,9 @@ teamspeak-music-bot/
|
||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
||||
| **网易云 API** | NeteaseCloudMusicApi |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
|
||||
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
||||
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
|
||||
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
|
||||
| **界面样式** | SCSS(YesPlayMusic 设计风格) |
|
||||
| **图标** | @iconify/vue |
|
||||
@@ -573,6 +578,12 @@ A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务
|
||||
**Q:端口 3200 被占用?**
|
||||
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
|
||||
|
||||
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
|
||||
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
|
||||
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
|
||||
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
|
||||
修好版本后重新 `npm install && npm run build` 并重启即可。
|
||||
|
||||
**Q:播放歌曲时报 FFmpeg EACCES 错误?**
|
||||
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
|
||||
```bash
|
||||
@@ -727,6 +738,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
|
||||
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
|
||||
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
|
||||
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
|
||||
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
|
||||
|
||||
## 致谢
|
||||
@@ -747,6 +759,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
|
||||
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
|
||||
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
|
||||
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
|
||||
|
||||
## 开源许可
|
||||
|
||||
|
||||
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`.
|
||||
Generated
+342
-352
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -17,7 +17,7 @@
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@honeybbq/teamspeak-client": "^0.2.1",
|
||||
"@koa/router": "^15.4.0",
|
||||
"@sansenjian/qq-music-api": "^2.2.10",
|
||||
"@sansenjian/qq-music-api": "~2.4.0",
|
||||
"axios": "^1.14.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"koa": "^3.2.0",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"NeteaseCloudMusicApi": "^4.30.0",
|
||||
"NeteaseCloudMusicApi": "~4.32.0",
|
||||
"pino": "^10.3.1",
|
||||
"ts3-nodejs-library": "^3.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
@@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BotInstance.getProviderFor — spotify routing", () => {
|
||||
it("getProviderFor routes 'spotify' to the injected spotify provider", () => {
|
||||
const spotify = { platform: "spotify" } as any;
|
||||
const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any;
|
||||
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
|
||||
});
|
||||
});
|
||||
+24
-2
@@ -23,6 +23,7 @@ import {
|
||||
occupancyFromClientList,
|
||||
shouldResumeOnReturn,
|
||||
} from "./auto-pause.js";
|
||||
import { isSpotifyUri } from "../music/spotify/webapi.js";
|
||||
|
||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||
@@ -36,6 +37,8 @@ export interface BotInstanceOptions {
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
localProvider?: MusicProvider;
|
||||
kugouProvider?: MusicProvider;
|
||||
spotifyProvider?: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -69,6 +72,8 @@ export class BotInstance extends EventEmitter {
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -98,6 +103,8 @@ export class BotInstance extends EventEmitter {
|
||||
this.bilibiliProvider = options.bilibiliProvider;
|
||||
this.youtubeProvider = options.youtubeProvider;
|
||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
||||
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -524,10 +531,12 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "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;
|
||||
}
|
||||
|
||||
@@ -540,6 +549,8 @@ export class BotInstance extends EventEmitter {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
if (flags.has("y")) return this.youtubeProvider;
|
||||
if (flags.has("k")) return this.kugouProvider;
|
||||
if (flags.has("s")) return this.spotifyProvider;
|
||||
return this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -576,6 +587,17 @@ export class BotInstance extends EventEmitter {
|
||||
);
|
||||
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;
|
||||
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
||||
this.effectiveDuration = result.trialDuration ?? song.duration;
|
||||
@@ -649,7 +671,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
||||
const p = this.config.commandPrefix;
|
||||
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`;
|
||||
if (!cmd.args) return `Usage: ${p}search <name> [-q|-k|-b|-y]`;
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const result = await provider.search(cmd.args, 8);
|
||||
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
|
||||
|
||||
+13
-1
@@ -75,6 +75,8 @@ export class BotManager extends EventEmitter {
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -92,7 +94,9 @@ export class BotManager extends EventEmitter {
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore,
|
||||
configPath: string,
|
||||
localProvider?: MusicProvider
|
||||
localProvider?: MusicProvider,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -100,6 +104,8 @@ export class BotManager extends EventEmitter {
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
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<{
|
||||
@@ -137,6 +143,8 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -276,6 +284,8 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -329,6 +339,8 @@ export class BotManager extends EventEmitter {
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -205,3 +205,61 @@ describe("adminGroups normalization", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,15 @@ export interface GuestModeConfig {
|
||||
permissions: GuestPermissions;
|
||||
}
|
||||
|
||||
export interface SpotifyConfig {
|
||||
enabled: boolean;
|
||||
backend: "auto" | "go-librespot" | "librespot";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
deviceName: string;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
webPort: number;
|
||||
locale: "zh" | "en";
|
||||
@@ -33,6 +42,7 @@ export interface BotConfig {
|
||||
// behind HTTPS-terminating proxies.
|
||||
trustProxy: boolean;
|
||||
guestMode: GuestModeConfig;
|
||||
spotify: SpotifyConfig;
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): BotConfig {
|
||||
@@ -69,6 +79,14 @@ export function getDefaultConfig(): BotConfig {
|
||||
playCollection: false,
|
||||
},
|
||||
},
|
||||
spotify: {
|
||||
enabled: false,
|
||||
backend: "auto",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
deviceName: "TSMusicBot",
|
||||
bitrate: 320,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,11 +136,32 @@ export function loadConfig(path: string): BotConfig {
|
||||
)
|
||||
: defaults.adminGroups;
|
||||
|
||||
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 {
|
||||
...defaults,
|
||||
...partial,
|
||||
adminGroups,
|
||||
guestMode: gm,
|
||||
spotify,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -8,6 +8,8 @@ import { NeteaseProvider } from "./music/netease.js";
|
||||
import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { KugouProvider } from "./music/kugou.js";
|
||||
import { SpotifyProvider } from "./music/spotify/provider.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
import { createAvatarStore } from "./data/avatars.js";
|
||||
import { createPermissionStore } from "./data/permissions.js";
|
||||
@@ -57,6 +59,15 @@ async function main() {
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
const bilibiliProvider = new BiliBiliProvider();
|
||||
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
|
||||
const kugouProvider = new KugouProvider();
|
||||
const 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 avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
@@ -66,6 +77,8 @@ async function main() {
|
||||
if (qqCookie) qqProvider.setCookie(qqCookie);
|
||||
const bilibiliCookie = cookieStore.load("bilibili");
|
||||
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
||||
const kugouCookie = cookieStore.load("kugou");
|
||||
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
||||
|
||||
const permissions = createPermissionStore(db.db);
|
||||
|
||||
@@ -79,7 +92,9 @@ async function main() {
|
||||
avatarStore,
|
||||
permissions,
|
||||
CONFIG_PATH,
|
||||
localProvider
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
spotifyProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -90,6 +105,8 @@ async function main() {
|
||||
qqProvider,
|
||||
bilibiliProvider,
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
spotifyProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,32 @@ export interface ApiServerManager {
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
|
||||
* actionable operator guidance, or null when it isn't a recognised
|
||||
* dependency/runtime mismatch. Exported for testing.
|
||||
*
|
||||
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
|
||||
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
|
||||
* that needs Node >=20.17 — either way the embedded server never binds, so
|
||||
* every QQ request fails downstream with ECONNREFUSED on the API port.
|
||||
*/
|
||||
export function describeQqApiStartupError(err: unknown): string | null {
|
||||
const e = (err ?? {}) as { code?: string; message?: string };
|
||||
const code = String(e.code ?? "");
|
||||
const msg = String(e.message ?? "");
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
|
||||
return (
|
||||
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
|
||||
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
|
||||
);
|
||||
}
|
||||
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
|
||||
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
@@ -100,11 +126,19 @@ export function createApiServerManager(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const hint = describeQqApiStartupError(err);
|
||||
if (hint) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
|
||||
+4
-4
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface CookieStore {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili"): string;
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
@@ -21,7 +21,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
);
|
||||
},
|
||||
|
||||
load(platform: "netease" | "qq" | "bilibili"): string {
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
try {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||
vip?: boolean;
|
||||
@@ -27,7 +27,7 @@ export interface Playlist {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
@@ -44,7 +44,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -72,7 +72,7 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)" };
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -11,7 +11,9 @@ export function createAuthRouter(
|
||||
qqProvider: MusicProvider,
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger,
|
||||
cookieStore?: CookieStore
|
||||
cookieStore?: CookieStore,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
// 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 {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -65,6 +69,7 @@ export function createAuthRouter(
|
||||
if (status === "confirmed") {
|
||||
const cookie = provider.getCookie();
|
||||
const plat = (platform as string) === "bilibili" ? "bilibili" as const
|
||||
: (platform as string) === "kugou" ? "kugou" as const
|
||||
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookie && cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
@@ -137,6 +142,7 @@ export function createAuthRouter(
|
||||
const provider = getProvider(platform);
|
||||
provider.setCookie(cookie);
|
||||
const plat = platform === "bilibili" ? "bilibili" as const
|
||||
: platform === "kugou" ? "kugou" as const
|
||||
: platform === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
|
||||
+21
-2
@@ -13,7 +13,9 @@ export function createMusicRouter(
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger,
|
||||
localProvider?: MusicProvider,
|
||||
config?: BotConfig
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
@@ -26,6 +28,8 @@ export function createMusicRouter(
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "local" && localProvider) return localProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -111,11 +115,17 @@ export function createMusicRouter(
|
||||
return;
|
||||
}
|
||||
const parsedLimit = parseInt(limit as string) || 20;
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
|
||||
// Spotify is intentionally EXCLUDED from unified /search/all in Stage 1:
|
||||
// its tracks are metadata-only (not yet playable) until the librespot audio
|
||||
// backend lands (Stage 2/3), so surfacing them in the default all-sources
|
||||
// view would only yield results that get skipped. Spotify search remains
|
||||
// available from its own tab via /search?platform=spotify.
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
neteaseProvider.search(q as string, parsedLimit),
|
||||
qqProvider.search(q as string, parsedLimit),
|
||||
bilibiliProvider.search(q as string, parsedLimit),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
@@ -123,6 +133,7 @@ export function createMusicRouter(
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
|
||||
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
@@ -286,6 +297,8 @@ export function createMusicRouter(
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
local: localProvider?.getQuality() ?? "original",
|
||||
kugou: kugouProvider?.getQuality() ?? "128",
|
||||
spotify: spotifyProvider?.getQuality() ?? "320",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,6 +318,12 @@ export function createMusicRouter(
|
||||
if (!platform || platform === "bilibili") {
|
||||
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");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createPlayerRouter(
|
||||
if (platform === "bilibili") return "-b";
|
||||
if (platform === "qq") return "-q";
|
||||
if (platform === "youtube") return "-y";
|
||||
if (platform === "kugou") return "-k";
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -115,7 +116,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -290,7 +291,7 @@ export function createPlayerRouter(
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -383,7 +384,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -614,7 +615,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
+4
-2
@@ -39,6 +39,8 @@ export interface WebServerOptions {
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
localProvider: MusicProvider;
|
||||
kugouProvider: MusicProvider;
|
||||
spotifyProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
@@ -124,7 +126,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
@@ -132,7 +134,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
));
|
||||
app.use(
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider)
|
||||
);
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||
|
||||
|
||||
+22
-10
@@ -44,11 +44,15 @@
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="mobileVolume"
|
||||
:value="mobileVolumeDisplay"
|
||||
class="m-volume-slider"
|
||||
@input="onMobileVolumeChange"
|
||||
@input="onMobileVolumeInput"
|
||||
@change="onMobileVolumeCommit"
|
||||
@pointerup="onMobileVolumeRelease"
|
||||
@pointercancel="onMobileVolumeRelease"
|
||||
@blur="onMobileVolumeRelease"
|
||||
/>
|
||||
<span class="m-volume-value">{{ mobileVolume }}</span>
|
||||
<span class="m-volume-value">{{ mobileVolumeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +83,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from './stores/player.js';
|
||||
import { useDecoupledSlider } from './composables/useDecoupledSlider.js';
|
||||
import { useWebSocket } from './composables/useWebSocket.js';
|
||||
import { useSession } from './composables/useSession.js';
|
||||
import Navbar from './components/Navbar.vue';
|
||||
@@ -99,7 +104,17 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { connect } = useWebSocket();
|
||||
const currentSong = computed(() => playerStore.currentSong);
|
||||
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
|
||||
// Volume slider decoupled from the 60fps updateMobileProgress() rAF re-render
|
||||
// so it isn't reset mid-drag (#111 — same root cause as the desktop player).
|
||||
const {
|
||||
display: mobileVolumeDisplay,
|
||||
onInput: onMobileVolumeInput,
|
||||
onChange: onMobileVolumeCommit,
|
||||
onRelease: onMobileVolumeRelease,
|
||||
} = useDecoupledSlider(
|
||||
() => playerStore.activeBot?.volume,
|
||||
(v) => playerStore.setVolume(v)
|
||||
);
|
||||
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
|
||||
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
|
||||
const mobileModeIcons: Record<string, string> = {
|
||||
@@ -118,17 +133,14 @@ let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
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
|
||||
? Math.min((playerStore.elapsed / duration) * 100, 100)
|
||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||
: 0;
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
}
|
||||
|
||||
function onMobileVolumeChange(e: Event) {
|
||||
const volume = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.setVolume(volume);
|
||||
}
|
||||
|
||||
function toggleMobileVolume() {
|
||||
mobileVolumeOpen.value = !mobileVolumeOpen.value;
|
||||
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
|
||||
|
||||
@@ -65,8 +65,12 @@
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="activeBot?.volume ?? 75"
|
||||
:value="volumeDisplay"
|
||||
@input="onVolumeInput"
|
||||
@change="onVolumeChange"
|
||||
@pointerup="onVolumeRelease"
|
||||
@pointercancel="onVolumeRelease"
|
||||
@blur="onVolumeRelease"
|
||||
class="volume-slider"
|
||||
/>
|
||||
</template>
|
||||
@@ -87,6 +91,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import { useDecoupledSlider } from '../composables/useDecoupledSlider.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Queue from './Queue.vue';
|
||||
|
||||
@@ -135,8 +140,9 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
// Use store.elapsed which interpolates from server ground truth
|
||||
currentElapsed.value = store.elapsed;
|
||||
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
|
||||
// from the server anchor on every frame so the clock ticks each second (#107).
|
||||
currentElapsed.value = store.liveElapsed();
|
||||
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
progressPercent.value = duration > 0
|
||||
@@ -184,10 +190,17 @@ function togglePlay() {
|
||||
}
|
||||
}
|
||||
|
||||
function onVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
store.setVolume(parseInt(target.value));
|
||||
}
|
||||
// Volume slider is decoupled from the per-frame rAF re-render so dragging the
|
||||
// thumb isn't reset every frame (#111). See useDecoupledSlider.
|
||||
const {
|
||||
display: volumeDisplay,
|
||||
onInput: onVolumeInput,
|
||||
onChange: onVolumeChange,
|
||||
onRelease: onVolumeRelease,
|
||||
} = useDecoupledSlider(
|
||||
() => activeBot.value?.volume,
|
||||
(v) => store.setVolume(v)
|
||||
);
|
||||
|
||||
const modeOrder = ['seq', 'loop', 'random', 'rloop'] as const;
|
||||
const modeIcons: Record<string, string> = {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }}</span>
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'"
|
||||
>{{ 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 class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -140,6 +140,16 @@ function formatDuration(seconds: number): string {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { Source } from '../stores/player.js';
|
||||
const LABELS: Record<Source, string> = {
|
||||
netease: '网易云',
|
||||
qq: 'QQ',
|
||||
kugou: '酷狗',
|
||||
spotify: 'Spotify',
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -10,10 +10,10 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local';
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq';
|
||||
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
|
||||
export interface BotStatus {
|
||||
id: string;
|
||||
@@ -47,7 +47,7 @@ export interface FavoritePlaylist {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
export interface TimingState {
|
||||
serverElapsed: number;
|
||||
serverSyncTime: number;
|
||||
wasPlaying: boolean;
|
||||
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
|
||||
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', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
@@ -73,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
|
||||
// Home page cache, split by source
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||
bilibiliPopular: [] as Song[],
|
||||
authStatus: { netease: false, qq: false },
|
||||
authStatus: { netease: false, qq: false, kugou: false, spotify: false },
|
||||
lastFetchTime: 0,
|
||||
|
||||
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||
@@ -111,26 +138,47 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!botId) return [];
|
||||
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 {
|
||||
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;
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
||||
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
||||
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
/** Sources that are currently logged in. Order: netease before qq. */
|
||||
availableSources(): Source[] {
|
||||
const s: Source[] = [];
|
||||
if (this.authStatus.netease) s.push('netease');
|
||||
if (this.authStatus.qq) s.push('qq');
|
||||
if (this.authStatus.kugou) s.push('kugou');
|
||||
if (this.authStatus.spotify) s.push('spotify');
|
||||
return s;
|
||||
},
|
||||
},
|
||||
|
||||
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 {
|
||||
if (!this.timings[botId]) {
|
||||
this.timings[botId] = defaultTiming();
|
||||
@@ -403,9 +451,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async pause() {
|
||||
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, {
|
||||
serverElapsed: this.elapsed,
|
||||
serverElapsed: this.liveElapsed(),
|
||||
wasPlaying: false,
|
||||
});
|
||||
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
|
||||
// fetch (e.g., user logged in/out as a different account), the
|
||||
// cached playlists belong to a different user and we MUST refetch.
|
||||
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
||||
const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
|
||||
]);
|
||||
const newAuth = {
|
||||
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
||||
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
|
||||
};
|
||||
const authChanged =
|
||||
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
|
||||
newAuth.netease !== this.authStatus.netease ||
|
||||
newAuth.qq !== this.authStatus.qq ||
|
||||
newAuth.kugou !== this.authStatus.kugou ||
|
||||
newAuth.spotify !== this.authStatus.spotify;
|
||||
this.authStatus.netease = newAuth.netease;
|
||||
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
|
||||
// home-data cache hit, so hearts stay correct across tabs/sessions.
|
||||
@@ -574,15 +632,30 @@ export const usePlayerStore = defineStore('player', {
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
||||
// so gate all three on auth like QQ.
|
||||
const kugouPromises = this.authStatus.kugou
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'kugou' } }),
|
||||
]
|
||||
: [
|
||||
Promise.resolve(emptyPlaylists),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...neteasePromises,
|
||||
...qqPromises,
|
||||
...kugouPromises,
|
||||
biliPromise,
|
||||
]);
|
||||
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
|
||||
|
||||
this.recommendPlaylists.netease =
|
||||
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
||||
@@ -596,6 +669,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
|
||||
this.userPlaylists.qq =
|
||||
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : [];
|
||||
this.recommendPlaylists.kugou =
|
||||
kgRecPL.status === 'fulfilled' ? (kgRecPL.value.data.playlists ?? []) : [];
|
||||
this.dailySongs.kugou =
|
||||
kgDaily.status === 'fulfilled' ? (kgDaily.value.data.songs ?? []) : [];
|
||||
this.userPlaylists.kugou =
|
||||
kgUserPL.status === 'fulfilled' ? (kgUserPL.value.data.playlists ?? []) : [];
|
||||
// bilibili popular: keep previous value on failure (it's an anonymous endpoint
|
||||
// unrelated to user auth state, and stale popular results are harmless)
|
||||
if (bili.status === 'fulfilled') {
|
||||
@@ -607,7 +686,9 @@ export const usePlayerStore = defineStore('player', {
|
||||
// cached for 5 minutes, otherwise the user has to hard-reload to
|
||||
// recover when connectivity returns.
|
||||
const authOk =
|
||||
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled';
|
||||
neAuthRes.status === 'fulfilled' ||
|
||||
qqAuthRes.status === 'fulfilled' ||
|
||||
kugouAuthRes.status === 'fulfilled';
|
||||
if (authOk) {
|
||||
this.lastFetchTime = Date.now();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
|
||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||
const all = readAll();
|
||||
const v = all[key];
|
||||
return v === 'netease' || v === 'qq' ? v : fallback;
|
||||
return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
|
||||
}
|
||||
|
||||
export function saveTabSource(key: TabKey, value: Source): void {
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
--brand-bilibili-15: rgba(0, 161, 214, 0.15);
|
||||
--brand-youtube: #ff0000;
|
||||
--brand-youtube-12: rgba(255, 0, 0, 0.12);
|
||||
--brand-kugou: #2ca2f9;
|
||||
--brand-kugou-12: rgba(44, 162, 249, 0.12);
|
||||
--brand-spotify: #1DB954;
|
||||
--brand-spotify-12: rgba(29, 185, 84, 0.12);
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
|
||||
+18
-3
@@ -41,13 +41,23 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div class="fm-icon-wrapper kugou">
|
||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">酷狗私人电台</div>
|
||||
<div class="fm-desc">个性化推荐歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
每日推荐
|
||||
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
|
||||
<SourceTabs :model-value="dailySourceSafe" @update:model-value="dailySource = $event" :sources="dailyAvailable" />
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
@@ -67,7 +77,7 @@
|
||||
<section class="section" v-if="recommendAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
推荐歌单
|
||||
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
|
||||
<SourceTabs :model-value="recommendSourceSafe" @update:model-value="recommendSource = $event" :sources="recommendAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -108,7 +118,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -173,6 +183,7 @@ const userPlaylistsExpanded = ref(false);
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
if (store.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
||||
@@ -358,6 +369,10 @@ onMounted(() => {
|
||||
&.qq {
|
||||
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||
}
|
||||
|
||||
&.kugou {
|
||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
|
||||
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
|
||||
|
||||
function syncLyrics() {
|
||||
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);
|
||||
// Only update when the active line actually changes
|
||||
if (idx !== activeLine.value && idx >= 0) {
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
:class="{ active: selectedSource === 'bilibili' }"
|
||||
@click="selectedSource = 'bilibili'"
|
||||
>B站</button>
|
||||
<button
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'kugou' }"
|
||||
@click="selectedSource = 'kugou'"
|
||||
>酷狗</button>
|
||||
<button
|
||||
v-if="hasLocalSongs"
|
||||
class="source-btn"
|
||||
@@ -89,7 +94,7 @@
|
||||
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'albums' }"
|
||||
@click="activeTab = 'albums'"
|
||||
@@ -97,7 +102,7 @@
|
||||
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'playlists' }"
|
||||
@click="activeTab = 'playlists'"
|
||||
@@ -182,12 +187,12 @@ const router = useRouter();
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'search-source';
|
||||
|
||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
|
||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
||||
|
||||
function loadSource(): SearchSource {
|
||||
try {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'netease';
|
||||
}
|
||||
@@ -232,7 +237,7 @@ watch(selectedSource, (src) => {
|
||||
|
||||
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
|
||||
watch(selectedSource, (src) => {
|
||||
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
|
||||
if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
|
||||
activeTab.value = 'songs';
|
||||
}
|
||||
});
|
||||
@@ -351,6 +356,7 @@ function badgeLabel(platform: string): string {
|
||||
if (platform === 'bilibili') return 'B站';
|
||||
if (platform === 'youtube') return 'YouTube';
|
||||
if (platform === 'local') return '本地';
|
||||
if (platform === 'kugou') return '酷狗';
|
||||
return '网易云';
|
||||
}
|
||||
|
||||
@@ -359,6 +365,7 @@ function badgeClass(platform: string): string {
|
||||
if (platform === 'bilibili') return 'badge-bilibili';
|
||||
if (platform === 'youtube') return 'badge-youtube';
|
||||
if (platform === 'local') return 'badge-local';
|
||||
if (platform === 'kugou') return 'badge-kugou';
|
||||
return 'badge-netease';
|
||||
}
|
||||
|
||||
@@ -645,6 +652,11 @@ onMounted(() => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-kugou {
|
||||
background: var(--brand-kugou-12);
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
@@ -377,6 +377,76 @@
|
||||
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kugou -->
|
||||
<div class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
|
||||
<div class="account-info">
|
||||
<div class="account-name">酷狗音乐</div>
|
||||
<div class="account-status" :class="{ logged: kugouAuth.loggedIn }">
|
||||
{{ kugouAuth.loggedIn ? `已登录: ${kugouAuth.nickname}` : '未登录' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-methods">
|
||||
<button
|
||||
class="login-btn"
|
||||
:class="{ active: kugouLoginMode === 'qr' }"
|
||||
@click="startQrLogin('kugou')"
|
||||
:disabled="kugouQr.loading"
|
||||
>
|
||||
<Icon icon="mdi:qrcode" />
|
||||
扫码登录
|
||||
</button>
|
||||
<button
|
||||
class="login-btn"
|
||||
:class="{ active: kugouLoginMode === 'cookie' }"
|
||||
@click="kugouLoginMode = 'cookie'"
|
||||
>
|
||||
<Icon icon="mdi:cookie" />
|
||||
Cookie登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="kugouLoginMode === 'qr'" class="qr-section">
|
||||
<div v-if="kugouQr.loading" class="qr-loading">
|
||||
<Icon icon="mdi:loading" class="spin" />
|
||||
生成二维码中...
|
||||
</div>
|
||||
<div v-else-if="kugouQr.dataUrl" class="qr-wrap">
|
||||
<img :src="kugouQr.dataUrl" class="qr-image" alt="QR Code" />
|
||||
<div class="qr-status" :class="kugouQr.status">
|
||||
<template v-if="kugouQr.status === 'waiting'">
|
||||
<Icon icon="mdi:cellphone" /> 请使用酷狗音乐APP扫码
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'scanned'">
|
||||
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'confirmed'">
|
||||
<Icon icon="mdi:check-circle" /> 登录成功!
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'expired'">
|
||||
<Icon icon="mdi:refresh" /> 二维码已过期
|
||||
<button class="btn-link" @click="startQrLogin('kugou')">重新生成</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie -->
|
||||
<div v-if="kugouLoginMode === 'cookie'" class="cookie-section">
|
||||
<textarea
|
||||
v-model="kugouCookie"
|
||||
class="textarea"
|
||||
placeholder="粘贴酷狗Cookie (token=...; userid=...)..."
|
||||
rows="3"
|
||||
/>
|
||||
<button class="btn-primary btn-save" @click="saveCookie('kugou')">保存Cookie</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio Quality requires quality -->
|
||||
@@ -792,6 +862,7 @@ const editForm = reactive({
|
||||
const neteaseCookie = ref('');
|
||||
const qqCookie = ref('');
|
||||
const bilibiliCookie = ref('');
|
||||
const kugouCookie = ref('');
|
||||
const commandPrefix = ref('!');
|
||||
|
||||
// Audio quality
|
||||
@@ -823,11 +894,13 @@ async function setQuality(q: string) {
|
||||
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const kugouLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
|
||||
// Auth status
|
||||
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
|
||||
// QR state
|
||||
interface QrState {
|
||||
@@ -847,22 +920,28 @@ const qqQr = reactive<QrState>({
|
||||
const bilibiliQr = reactive<QrState>({
|
||||
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
|
||||
});
|
||||
const kugouQr = reactive<QrState>({
|
||||
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
|
||||
});
|
||||
|
||||
function getQrState(platform: string): QrState {
|
||||
if (platform === 'bilibili') return bilibiliQr;
|
||||
if (platform === 'kugou') return kugouQr;
|
||||
return platform === 'netease' ? neteaseQr : qqQr;
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const [nRes, qRes, bRes] = await Promise.all([
|
||||
const [nRes, qRes, bRes, kRes] = await Promise.all([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
]);
|
||||
Object.assign(neteaseAuth, nRes.data);
|
||||
Object.assign(qqAuth, qRes.data);
|
||||
Object.assign(bilibiliAuth, bRes.data);
|
||||
Object.assign(kugouAuth, kRes.data);
|
||||
} catch {
|
||||
// API not ready
|
||||
}
|
||||
@@ -872,6 +951,7 @@ async function startQrLogin(platform: string) {
|
||||
const qr = getQrState(platform);
|
||||
if (platform === 'netease') neteaseLoginMode.value = 'qr';
|
||||
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
|
||||
else if (platform === 'kugou') kugouLoginMode.value = 'qr';
|
||||
else qqLoginMode.value = 'qr';
|
||||
|
||||
// Stop existing poll
|
||||
@@ -889,13 +969,15 @@ async function startQrLogin(platform: string) {
|
||||
if (qrImg) {
|
||||
qr.dataUrl = qrImg;
|
||||
} else {
|
||||
// QR codes must be dark-on-light to stay scannable. Do NOT invert the
|
||||
// colours for the dark theme: many in-app scanners (notably the Kugou
|
||||
// music app) cannot decode a light-on-dark QR, so a themed code looks
|
||||
// fine on screen but silently fails to scan. The white quiet-zone frames
|
||||
// it cleanly in dark mode anyway.
|
||||
qr.dataUrl = await QRCode.toDataURL(qrUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: store.theme === 'dark' ? '#ffffff' : '#000000',
|
||||
light: store.theme === 'dark' ? '#2a2a2a' : '#ffffff',
|
||||
},
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1032,7 +1114,9 @@ async function toggleBot(botId: string, connected: boolean) {
|
||||
}
|
||||
|
||||
async function saveCookie(platform: string) {
|
||||
const cookie = platform === 'bilibili' ? bilibiliCookie.value : platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||
const cookie = platform === 'bilibili' ? bilibiliCookie.value
|
||||
: platform === 'kugou' ? kugouCookie.value
|
||||
: platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||
if (!cookie) return;
|
||||
try {
|
||||
await axios.post('/api/auth/cookie', { platform, cookie });
|
||||
@@ -1557,6 +1641,7 @@ onUnmounted(() => {
|
||||
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
|
||||
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
|
||||
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
|
||||
if (kugouQr.pollTimer) clearInterval(kugouQr.pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1698,6 +1783,10 @@ onUnmounted(() => {
|
||||
&.bilibili-icon {
|
||||
color: var(--brand-bilibili);
|
||||
}
|
||||
|
||||
&.kugou-icon {
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
}
|
||||
|
||||
.account-name {
|
||||
|
||||
Reference in new issue
Block a user