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