mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8743a904f | ||
|
|
f6e42811a5 | ||
|
|
08260182a9 | ||
|
|
de4343cfbb | ||
|
|
af50d69b00 | ||
|
|
e1439a5899 | ||
|
|
5ae5168b6b | ||
|
|
4cb1da29d4 | ||
|
|
f9caea6c79 | ||
|
|
a12c419dd2 | ||
|
|
af326a37b7 | ||
|
|
45f5d236c1 | ||
|
|
ea6820204d | ||
|
|
e849db2286 | ||
|
|
e12cbf8863 |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">TSMusicBot</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,6 +15,7 @@
|
||||
<img src="https://img.shields.io/badge/许可证-MIT-green" />
|
||||
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
|
||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
@@ -26,19 +27,20 @@
|
||||
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
|
||||
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
|
||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||
- **播放历史** — 自动记录所有播放过的歌曲
|
||||
@@ -299,19 +301,19 @@ sudo systemctl start tsmusicbot
|
||||
- 端口(默认 9987,自托管或非标准端口请填写实际值)
|
||||
- 机器人昵称
|
||||
- 可选:服务器密码、默认频道
|
||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
|
||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
|
||||
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
||||
|
||||
### WebUI 页面说明
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
|
||||
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
|
||||
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
|
||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||
| **历史** | 播放历史记录 |
|
||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||
|
||||
### TeamSpeak 文字命令
|
||||
|
||||
@@ -321,9 +323,10 @@ sudo systemctl start tsmusicbot
|
||||
|------|------|
|
||||
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
|
||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||
| `!play -k <歌名>` | 从酷狗音乐搜索 |
|
||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
|
||||
| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||
@@ -337,9 +340,10 @@ sudo systemctl start tsmusicbot
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||
| `!album <ID>` | 加载专辑 |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
|
||||
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
||||
| `!lyrics` | 显示当前歌词 |
|
||||
| `!now` | 当前播放信息 |
|
||||
| `!vote` | 投票跳过当前歌曲 |
|
||||
@@ -406,6 +410,7 @@ teamspeak-music-bot/
|
||||
│ │ ├── netease.ts # 网易云音乐适配器
|
||||
│ │ ├── qq.ts # QQ 音乐适配器
|
||||
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
||||
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
|
||||
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
|
||||
│ │ ├── auth.ts # Cookie 持久化存储
|
||||
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
|
||||
@@ -456,8 +461,9 @@ teamspeak-music-bot/
|
||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
||||
| **网易云 API** | NeteaseCloudMusicApi |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
|
||||
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
||||
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
|
||||
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
|
||||
| **界面样式** | SCSS(YesPlayMusic 设计风格) |
|
||||
| **图标** | @iconify/vue |
|
||||
@@ -572,6 +578,12 @@ A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务
|
||||
**Q:端口 3200 被占用?**
|
||||
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
|
||||
|
||||
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
|
||||
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
|
||||
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
|
||||
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
|
||||
修好版本后重新 `npm install && npm run build` 并重启即可。
|
||||
|
||||
**Q:播放歌曲时报 FFmpeg EACCES 错误?**
|
||||
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
|
||||
```bash
|
||||
@@ -629,10 +641,11 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
|
||||
### 最新版本
|
||||
|
||||
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||
|
||||
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
|
||||
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
||||
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
|
||||
@@ -725,6 +738,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
|
||||
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
|
||||
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
|
||||
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
|
||||
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
|
||||
|
||||
## 致谢
|
||||
@@ -745,6 +759,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
|
||||
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
|
||||
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
|
||||
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
|
||||
|
||||
## 开源许可
|
||||
|
||||
|
||||
Generated
+342
-352
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -17,7 +17,7 @@
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@honeybbq/teamspeak-client": "^0.2.1",
|
||||
"@koa/router": "^15.4.0",
|
||||
"@sansenjian/qq-music-api": "^2.2.10",
|
||||
"@sansenjian/qq-music-api": "~2.4.0",
|
||||
"axios": "^1.14.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"koa": "^3.2.0",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"NeteaseCloudMusicApi": "^4.30.0",
|
||||
"NeteaseCloudMusicApi": "~4.32.0",
|
||||
"pino": "^10.3.1",
|
||||
"ts3-nodejs-library": "^3.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
+80
-3
@@ -35,6 +35,8 @@ export interface BotInstanceOptions {
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
localProvider?: MusicProvider;
|
||||
kugouProvider?: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -67,6 +69,8 @@ export class BotInstance extends EventEmitter {
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -95,6 +99,8 @@ export class BotInstance extends EventEmitter {
|
||||
this.qqProvider = options.qqProvider;
|
||||
this.bilibiliProvider = options.bilibiliProvider;
|
||||
this.youtubeProvider = options.youtubeProvider;
|
||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -147,6 +153,41 @@ export class BotInstance extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
isLocalAudioEnabled(): boolean {
|
||||
return this.config.localAudioEnabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-aware cleanup of uploaded local audio files. Delegates to the
|
||||
* local provider, which deletes a file only when it has been played AND is
|
||||
* no longer referenced by ANY bot's queue — so loop replays, prev, the song
|
||||
* being re-started, and the same upload queued on another bot are all safe.
|
||||
* Call this AFTER the queue mutation, so released songs are unreferenced
|
||||
* (and deleted) while songs that remain queued are preserved.
|
||||
*/
|
||||
cleanupQueuedLocalSongs(reason: string): void {
|
||||
this.sweepLocalAudio(reason);
|
||||
}
|
||||
|
||||
private sweepLocalAudio(reason: string): void {
|
||||
const provider = this.localProvider as MusicProvider & {
|
||||
sweepUnreferenced?: () => string[];
|
||||
};
|
||||
if (typeof provider.sweepUnreferenced !== "function") return;
|
||||
try {
|
||||
const deleted = provider.sweepUnreferenced();
|
||||
if (deleted.length) {
|
||||
this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files");
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, reason }, "Local audio cleanup failed");
|
||||
}
|
||||
}
|
||||
|
||||
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
|
||||
return !!a && !!b && a.platform === b.platform && a.id === b.id;
|
||||
}
|
||||
|
||||
private setupTsEvents(): void {
|
||||
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
||||
this.handleTextMessage(msg).catch((err) => {
|
||||
@@ -161,6 +202,8 @@ export class BotInstance extends EventEmitter {
|
||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||
this.connected = false;
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
// A lifecycle change must not leave a stale auto-resume armed.
|
||||
this.autoPaused = false;
|
||||
// Only emit externally once per lifecycle so clients don't see a
|
||||
@@ -242,6 +285,8 @@ export class BotInstance extends EventEmitter {
|
||||
disconnect(): void {
|
||||
this._cancelIdleTimer();
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
this.connected = false;
|
||||
if (!this.disconnectEmitted) {
|
||||
this.disconnectEmitted = true;
|
||||
@@ -482,9 +527,11 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "youtube") return this.youtubeProvider;
|
||||
if (platform === "local") return this.localProvider;
|
||||
if (platform === "kugou") return this.kugouProvider;
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -497,6 +544,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
if (flags.has("y")) return this.youtubeProvider;
|
||||
if (flags.has("k")) return this.kugouProvider;
|
||||
return this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -506,6 +554,10 @@ export class BotInstance extends EventEmitter {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
|
||||
return false;
|
||||
}
|
||||
if (song.platform === "local" && !this.isLocalAudioEnabled()) {
|
||||
this.logger.warn({ songId: song.id, name: song.name }, "Local audio playback disabled — refusing track");
|
||||
return false;
|
||||
}
|
||||
// Clear any accumulated skip votes — every fresh track starts with a
|
||||
// clean slate, regardless of which code path loaded it (cmdPlay,
|
||||
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
|
||||
@@ -602,7 +654,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
||||
const p = this.config.commandPrefix;
|
||||
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`;
|
||||
if (!cmd.args) return `Usage: ${p}search <name> [-q|-k|-b|-y]`;
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const result = await provider.search(cmd.args, 8);
|
||||
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
|
||||
@@ -621,6 +673,10 @@ export class BotInstance extends EventEmitter {
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const song0 = song!;
|
||||
const previous = this.queue.current();
|
||||
if (previous && !this.isSameSong(previous, song0)) {
|
||||
this.player.stop();
|
||||
}
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
this.queue.add({ ...song0 });
|
||||
@@ -629,6 +685,10 @@ export class BotInstance extends EventEmitter {
|
||||
// Reset failure counter on user-initiated play
|
||||
this.player.resetFailures();
|
||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||
// Sweep AFTER the new song is queued+resolved: the replaced songs are no
|
||||
// longer referenced (and get deleted), but song0 — if it is the same local
|
||||
// upload that was already playing — stays referenced and is preserved.
|
||||
this.sweepLocalAudio("replaced");
|
||||
if (!ok) return `Cannot play: ${song0.name}`;
|
||||
return `Now playing: ${song0.name} - ${song0.artist}`;
|
||||
}
|
||||
@@ -708,6 +768,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.player.stop();
|
||||
this.autoPaused = false;
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("stopped");
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||
@@ -766,6 +827,7 @@ export class BotInstance extends EventEmitter {
|
||||
private cmdClear(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("queue_cleared");
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||
@@ -779,6 +841,9 @@ export class BotInstance extends EventEmitter {
|
||||
if (isNaN(index) || index < 0) return "Usage: !remove <number>";
|
||||
const removed = this.queue.remove(index);
|
||||
if (!removed) return "Invalid position";
|
||||
// Sweep after the entry is gone — the file is deleted only if no other
|
||||
// queue position (or bot) still references this upload.
|
||||
this.sweepLocalAudio("removed_from_queue");
|
||||
this.emit("stateChange");
|
||||
return `Removed: ${removed.name}`;
|
||||
}
|
||||
@@ -838,6 +903,7 @@ export class BotInstance extends EventEmitter {
|
||||
const songs = await provider.getPlaylistSongs(playlistId);
|
||||
if (songs.length === 0) return "Playlist is empty or not found";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
@@ -845,6 +911,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -873,6 +940,7 @@ export class BotInstance extends EventEmitter {
|
||||
const songs = await provider.getAlbumSongs(albumId);
|
||||
if (songs.length === 0) return "Album is empty or not found";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
@@ -880,6 +948,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -902,6 +971,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (songs.length === 0)
|
||||
return "No FM songs available (need to login first)";
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
@@ -913,6 +983,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
|
||||
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||
@@ -935,6 +1006,7 @@ export class BotInstance extends EventEmitter {
|
||||
filtered = result.songs.slice(0, 20);
|
||||
}
|
||||
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
for (const song of filtered) {
|
||||
@@ -945,6 +1017,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.sweepLocalAudio("queue_replaced");
|
||||
this.emit("stateChange");
|
||||
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
@@ -1050,10 +1123,10 @@ export class BotInstance extends EventEmitter {
|
||||
async playNext(maxRetries = 3): Promise<boolean> {
|
||||
if (this.isAdvancing || !this.connected) return false;
|
||||
this.isAdvancing = true;
|
||||
let started = false;
|
||||
try {
|
||||
this.voteSkipUsers.clear();
|
||||
const next = this.queue.next();
|
||||
let started = false;
|
||||
if (next) {
|
||||
started = await this.resolveAndPlay(next);
|
||||
if (!started) {
|
||||
@@ -1093,6 +1166,10 @@ export class BotInstance extends EventEmitter {
|
||||
this.emit("stateChange");
|
||||
return started;
|
||||
} finally {
|
||||
// Reference-aware sweep: a finished local song that still sits in the
|
||||
// queue (sequential history, loop/repeat, or queued on another bot) is
|
||||
// preserved; only uploads no longer referenced anywhere are deleted.
|
||||
this.sweepLocalAudio("playback_finished");
|
||||
this.isAdvancing = false;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -74,6 +74,8 @@ export class BotManager extends EventEmitter {
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -90,13 +92,23 @@ export class BotManager extends EventEmitter {
|
||||
logger: Logger,
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore,
|
||||
configPath: string
|
||||
configPath: string,
|
||||
localProvider?: MusicProvider,
|
||||
kugouProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
this.qqProvider = qqProvider;
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
this.youtubeProvider = new YouTubeProvider();
|
||||
this.localProvider = localProvider ?? neteaseProvider;
|
||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||
const referenceable = this.localProvider as Partial<{
|
||||
setInUseResolver: (resolver: () => Set<string>) => void;
|
||||
}>;
|
||||
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
|
||||
this.database = database;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
@@ -127,6 +139,8 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -210,6 +224,18 @@ export class BotManager extends EventEmitter {
|
||||
return Array.from(this.bots.values());
|
||||
}
|
||||
|
||||
/** Local upload ids still referenced by any bot's queue. The local provider
|
||||
* uses this to avoid deleting a file another queue/bot is still using. */
|
||||
getReferencedLocalSongIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const bot of this.bots.values()) {
|
||||
for (const song of bot.getQueueManager().list()) {
|
||||
if (song.platform === "local") ids.add(song.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async startBot(id: string): Promise<void> {
|
||||
const oldBot = this.bots.get(id);
|
||||
if (!oldBot) throw new Error(`Bot ${id} not found`);
|
||||
@@ -253,6 +279,8 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -305,6 +333,8 @@ export class BotManager extends EventEmitter {
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface BotConfig {
|
||||
autoReturnDelay: number;
|
||||
autoPauseOnEmpty: boolean;
|
||||
idleTimeoutMinutes: number;
|
||||
/** Enable uploading and playback of server-stored local audio files. */
|
||||
localAudioEnabled: boolean;
|
||||
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||
// Leave empty to use the browser's current origin. Example:
|
||||
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||
@@ -50,6 +52,7 @@ export function getDefaultConfig(): BotConfig {
|
||||
// clients are present). Users can opt in from the web UI.
|
||||
autoPauseOnEmpty: false,
|
||||
idleTimeoutMinutes: 0,
|
||||
localAudioEnabled: true,
|
||||
publicUrl: "",
|
||||
trustProxy: false,
|
||||
guestMode: {
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -7,6 +7,8 @@ import { createApiServerManager } from "./music/api-server.js";
|
||||
import { NeteaseProvider } from "./music/netease.js";
|
||||
import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { KugouProvider } from "./music/kugou.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
import { createAvatarStore } from "./data/avatars.js";
|
||||
import { createPermissionStore } from "./data/permissions.js";
|
||||
@@ -25,6 +27,7 @@ const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
||||
const LOG_DIR = path.join(DATA_DIR, "logs");
|
||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
|
||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||
|
||||
async function main() {
|
||||
@@ -54,6 +57,8 @@ async function main() {
|
||||
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
const bilibiliProvider = new BiliBiliProvider();
|
||||
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
|
||||
const kugouProvider = new KugouProvider();
|
||||
|
||||
const cookieStore = createCookieStore(COOKIE_DIR);
|
||||
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
@@ -63,6 +68,8 @@ async function main() {
|
||||
if (qqCookie) qqProvider.setCookie(qqCookie);
|
||||
const bilibiliCookie = cookieStore.load("bilibili");
|
||||
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
||||
const kugouCookie = cookieStore.load("kugou");
|
||||
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
||||
|
||||
const permissions = createPermissionStore(db.db);
|
||||
|
||||
@@ -75,7 +82,9 @@ async function main() {
|
||||
logger,
|
||||
avatarStore,
|
||||
permissions,
|
||||
CONFIG_PATH
|
||||
CONFIG_PATH,
|
||||
localProvider,
|
||||
kugouProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -85,6 +94,8 @@ async function main() {
|
||||
neteaseProvider,
|
||||
qqProvider,
|
||||
bilibiliProvider,
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeQqApiStartupError } from "./api-server.js";
|
||||
|
||||
describe("describeQqApiStartupError", () => {
|
||||
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
||||
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
|
||||
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
|
||||
expect(hint).toMatch(/~2\.4\.0/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
|
||||
const hint = describeQqApiStartupError(
|
||||
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
|
||||
);
|
||||
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
|
||||
});
|
||||
|
||||
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
|
||||
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
|
||||
expect(hint).toMatch(/Node >=20\.17/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
|
||||
expect(describeQqApiStartupError(undefined)).toBeNull();
|
||||
expect(describeQqApiStartupError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
+38
-4
@@ -14,6 +14,32 @@ export interface ApiServerManager {
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
|
||||
* actionable operator guidance, or null when it isn't a recognised
|
||||
* dependency/runtime mismatch. Exported for testing.
|
||||
*
|
||||
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
|
||||
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
|
||||
* that needs Node >=20.17 — either way the embedded server never binds, so
|
||||
* every QQ request fails downstream with ECONNREFUSED on the API port.
|
||||
*/
|
||||
export function describeQqApiStartupError(err: unknown): string | null {
|
||||
const e = (err ?? {}) as { code?: string; message?: string };
|
||||
const code = String(e.code ?? "");
|
||||
const msg = String(e.message ?? "");
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
|
||||
return (
|
||||
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
|
||||
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
|
||||
);
|
||||
}
|
||||
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
|
||||
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
@@ -100,10 +126,18 @@ export function createApiServerManager(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
const hint = describeQqApiStartupError(err);
|
||||
if (hint) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+4
-4
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface CookieStore {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili"): string;
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
@@ -12,7 +12,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
@@ -21,7 +21,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
);
|
||||
},
|
||||
|
||||
load(platform: "netease" | "qq" | "bilibili"): string {
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
describe("mapKugouSongs", () => {
|
||||
it("maps the mobile-search song shape to a Song with platform 'kugou'", () => {
|
||||
// Shape captured live from mobilecdn.kugou.com/api/v3/search/song.
|
||||
const raw = {
|
||||
hash: "B3A52A7A958BF0AED0EBFBA2E9A818B7",
|
||||
album_audio_id: 32100650,
|
||||
album_id: "966846",
|
||||
songname: "晴天",
|
||||
singername: "周杰伦",
|
||||
duration: 269,
|
||||
};
|
||||
const [song] = mapKugouSongs([raw]);
|
||||
expect(song.platform).toBe("kugou");
|
||||
expect(song.name).toBe("晴天");
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.duration).toBe(269);
|
||||
// The id must round-trip hash + album_audio_id + album_id so getSongUrl
|
||||
// can resolve a stream from a search result.
|
||||
expect(song.id).toBe("b3a52a7a958bf0aed0ebfba2e9a818b7|32100650|966846");
|
||||
});
|
||||
|
||||
it("treats nested audio_info durations as milliseconds and flat search durations as seconds", () => {
|
||||
// List endpoints: ms under audio_info.
|
||||
expect(mapKugouSong({ audio_info: { hash: "abc", duration: 215000 } }).duration).toBe(215);
|
||||
// Search endpoint: a long-form track's flat `duration` stays seconds (no /1000).
|
||||
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 10800 }).duration).toBe(10800);
|
||||
});
|
||||
|
||||
it("falls back to splitting '歌手 - 歌名' from the filename", () => {
|
||||
const song = mapKugouSong({ FileHash: "deadbeef", filename: "周杰伦 - 稻香", Duration: 200 });
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.name).toBe("稻香");
|
||||
});
|
||||
|
||||
it("uses PascalCase fields from the gateway shape and skips entries with no hash", () => {
|
||||
const songs = mapKugouSongs([
|
||||
{ FileHash: "aa", SongName: "n", SingerName: "s", MixSongID: 1, AlbumID: 2 },
|
||||
{ songname: "no hash" } as any,
|
||||
]);
|
||||
expect(songs).toHaveLength(1);
|
||||
expect(songs[0].id).toBe("aa|1|2");
|
||||
});
|
||||
|
||||
it("returns [] for non-array input", () => {
|
||||
expect(mapKugouSongs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps the NESTED album/playlist track shape (base + audio_info)", () => {
|
||||
// Shape captured live from /album/songs (叶惠美).
|
||||
const raw = {
|
||||
base: { album_id: 966846, album_audio_id: 32100648, audio_name: "以父之名", author_name: "周杰伦" },
|
||||
audio_info: { hash: "DBC0207490EB51153EF933EF5A7E98E4", duration: 342047 },
|
||||
};
|
||||
const [song] = mapKugouSongs([raw]);
|
||||
expect(song.name).toBe("以父之名");
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.duration).toBe(342); // ms → s
|
||||
expect(song.id).toBe("dbc0207490eb51153ef933ef5a7e98e4|32100648|966846");
|
||||
});
|
||||
});
|
||||
|
||||
describe("krcToLrc", () => {
|
||||
it("converts KRC [startMs,durMs] line timestamps + strips word timings into parseable LRC", () => {
|
||||
// Shape captured live from lyrics.kugou.com (周杰伦 - 晴天).
|
||||
const krc = [
|
||||
"[ti:晴天]",
|
||||
"[ar:周杰伦]",
|
||||
"[0,2250]<0,160,0>晴<160,160,0>天",
|
||||
"[63000,1800]<0,200,0>故<200,200,0>事",
|
||||
].join("\n");
|
||||
const lrc = krcToLrc(krc);
|
||||
expect(lrc).toContain("[00:00.00]晴天");
|
||||
expect(lrc).toContain("[01:03.00]故事");
|
||||
expect(lrc).not.toMatch(/<\d+,\d+,\d+>/); // word timings stripped
|
||||
|
||||
// And the shared LRC parser must now actually produce timed lines.
|
||||
const lines = parseLyrics(lrc);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toEqual({ time: 0, text: "晴天" });
|
||||
expect(lines[1].time).toBe(63);
|
||||
expect(lines[1].text).toBe("故事");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouAlbums", () => {
|
||||
it("maps album shape and normalises {size} covers to https", () => {
|
||||
const [album] = mapKugouAlbums([
|
||||
{ album_id: 966846, album_name: "叶惠美", singername: "周杰伦", sizable_cover: "http://imge.kugou.com/x/{size}/abc.jpg", songcount: 11 },
|
||||
]);
|
||||
expect(album.platform).toBe("kugou");
|
||||
expect(album.id).toBe("966846");
|
||||
expect(album.name).toBe("叶惠美");
|
||||
expect(album.coverUrl).toBe("https://imge.kugou.com/x/240/abc.jpg");
|
||||
expect(album.songCount).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — playlist (get_other_list_file) shape", () => {
|
||||
it("splits the combined `name` and uses `mixsongid` as the audio id", () => {
|
||||
// Shape captured live from /pubsongs/v2/get_other_list_file_nofilt (我喜欢).
|
||||
// The combined "歌手 - 歌名" is in `name`; there is no separate songname.
|
||||
const raw = {
|
||||
name: "KOKIA - ありがとう… (谢谢…)",
|
||||
hash: "E9A08A98614DD992F11A68A5E5F1C79F",
|
||||
album_id: "1491689",
|
||||
mixsongid: 37533796,
|
||||
timelen: 248528, // ms
|
||||
cover: "http://imge.kugou.com/stdmusic/{size}/20210113/x.jpg",
|
||||
};
|
||||
const song = mapKugouSong(raw);
|
||||
expect(song.artist).toBe("KOKIA");
|
||||
expect(song.name).toBe("ありがとう… (谢谢…)");
|
||||
// hash lowercased | mixsongid | album_id — so getSongUrl can resolve it.
|
||||
expect(song.id).toBe("e9a08a98614dd992f11a68a5e5f1c79f|37533796|1491689");
|
||||
expect(song.duration).toBe(249); // timelen ms → s
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/20210113/x.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — cover art per endpoint", () => {
|
||||
it("reads `sizable_cover` (daily/FM shape) and resolves {size}→240, http→https", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "http://imge.kugou.com/stdmusic/{size}/y.jpg",
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/y.jpg");
|
||||
});
|
||||
|
||||
it("falls back to `trans_param.union_cover` (search shape, no top-level cover)", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
|
||||
it("returns an empty coverUrl when no cover field is present", () => {
|
||||
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 200 }).coverUrl).toBe("");
|
||||
});
|
||||
|
||||
it("skips an empty-string cover field (Kugou returns '') and uses the next non-empty source", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "", // present but empty — must NOT mask the real cover below
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouPlaylists", () => {
|
||||
it("maps the user-playlist shape (/v7/get_all_list info), keying id on global_collection_id", () => {
|
||||
// Shape captured live from /v7/get_all_list (我喜欢).
|
||||
const [pl] = mapKugouPlaylists([
|
||||
{ name: "我喜欢", count: 7, global_collection_id: "collection_3_2526507197_2_0", listid: 2, type: 0 },
|
||||
]);
|
||||
expect(pl.platform).toBe("kugou");
|
||||
expect(pl.name).toBe("我喜欢");
|
||||
expect(pl.songCount).toBe(7);
|
||||
// Must be the global_collection_id — the only id getPlaylistSongs can open.
|
||||
expect(pl.id).toBe("collection_3_2526507197_2_0");
|
||||
});
|
||||
|
||||
it("maps the recommend shape (/v2/special_recommend), preferring global_collection_id over specialid", () => {
|
||||
// Shape captured live from /v2/special_recommend special_list.
|
||||
const pl = mapKugouPlaylist({
|
||||
specialname: "米津玄师:蒙着眼睛也能炸翻全场",
|
||||
specialid: 1154672,
|
||||
global_collection_id: "collection_1_1029965246_1154672_0",
|
||||
pic: "http://imge.kugou.com/specialimg/{size}/a.jpg",
|
||||
percount: 0,
|
||||
});
|
||||
expect(pl.id).toBe("collection_1_1029965246_1154672_0");
|
||||
expect(pl.name).toBe("米津玄师:蒙着眼睛也能炸翻全场");
|
||||
expect(pl.coverUrl).toBe("https://imge.kugou.com/specialimg/240/a.jpg");
|
||||
});
|
||||
|
||||
it("drops entries whose only id is a non-openable specialid/listid (not a global_collection_id)", () => {
|
||||
// getPlaylistSongs can only open a global_collection_id, so a numeric
|
||||
// specialid/listid would be a dead id — such entries must be dropped.
|
||||
expect(mapKugouPlaylists([{ specialname: "x", specialid: 1154672, listid: 9 }])).toHaveLength(0);
|
||||
expect(mapKugouPlaylists([{ name: "no id" }])).toHaveLength(0);
|
||||
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1028
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { LocalMusicProvider } from "./local.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Seed real files + an index.json so we can exercise the cleanup lifecycle
|
||||
// without invoking the ffmpeg duration probe that uploadAudio runs.
|
||||
function makeRecord(id: string, bytes = 16) {
|
||||
const filePath = join(dir, `${id}.mp3`);
|
||||
writeFileSync(filePath, Buffer.alloc(bytes, 1));
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
artist: "本地上传",
|
||||
album: "本地音乐",
|
||||
duration: 0,
|
||||
coverUrl: "",
|
||||
platform: "local" as const,
|
||||
filePath,
|
||||
originalName: `${id}.mp3`,
|
||||
uploadedAt: "1970-01-01T00:00:00.000Z",
|
||||
size: bytes,
|
||||
mimeType: "audio/mpeg",
|
||||
};
|
||||
}
|
||||
|
||||
function seed(records: ReturnType<typeof makeRecord>[]) {
|
||||
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
|
||||
}
|
||||
|
||||
describe("LocalMusicProvider cleanup lifecycle", () => {
|
||||
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
const c = makeRecord("c");
|
||||
seed([a, b, c]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // played, but referenced
|
||||
await p.getSongUrl("b"); // played and unreferenced
|
||||
// "c" was never played (e.g. uploaded but not queued)
|
||||
|
||||
const deleted = p.sweepUnreferenced();
|
||||
|
||||
expect(deleted).toEqual(["b"]);
|
||||
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
|
||||
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
|
||||
expect(existsSync(c.filePath)).toBe(true); // never played → kept
|
||||
});
|
||||
|
||||
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const refs = new Set<string>(["a"]); // loop queue still references it
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // first pass plays it
|
||||
p.sweepUnreferenced(); // "playback_finished" sweep
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
|
||||
});
|
||||
|
||||
it("re-playing a queued local song does not delete it (play-song order)", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
// Mirror the fixed endpoint order: the song is (re)added to the queue
|
||||
// BEFORE the sweep runs, so it is referenced when we sweep.
|
||||
const refs = new Set<string>(["a"]);
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a"); // played once
|
||||
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
expect(await p.getSongUrl("a")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a played file once it leaves every queue", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
let refs = new Set<string>(["a"]);
|
||||
p.setInUseResolver(() => refs);
|
||||
|
||||
await p.getSongUrl("a");
|
||||
p.sweepUnreferenced();
|
||||
expect(existsSync(a.filePath)).toBe(true); // still queued
|
||||
|
||||
refs = new Set<string>(); // queue cleared
|
||||
p.sweepUnreferenced();
|
||||
expect(existsSync(a.filePath)).toBe(false); // now removed
|
||||
expect(await p.getSongUrl("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("never deletes anything when the reference resolver throws", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir);
|
||||
p.setInUseResolver(() => {
|
||||
throw new Error("manager unavailable");
|
||||
});
|
||||
await p.getSongUrl("a");
|
||||
expect(p.sweepUnreferenced()).toEqual([]);
|
||||
expect(existsSync(a.filePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider upload validation", () => {
|
||||
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({
|
||||
buffer: Buffer.from("malicious"),
|
||||
originalName: "evil.exe",
|
||||
mimeType: "application/octet-stream",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown extension even when the mime claims audio", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({
|
||||
buffer: Buffer.from("x"),
|
||||
originalName: "evil.html",
|
||||
mimeType: "audio/mpeg",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty file", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
await expect(
|
||||
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider quota", () => {
|
||||
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
seed([b, a]); // newest-first: b newer than a
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
|
||||
p.setInUseResolver(() => new Set<string>());
|
||||
|
||||
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
|
||||
await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
|
||||
expect(existsSync(b.filePath)).toBe(true);
|
||||
const result = await p.search("");
|
||||
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
|
||||
});
|
||||
|
||||
it("does not evict a referenced upload even when over the cap", async () => {
|
||||
const a = makeRecord("a");
|
||||
const b = makeRecord("b");
|
||||
seed([b, a]);
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
|
||||
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
|
||||
|
||||
await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
|
||||
});
|
||||
|
||||
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
|
||||
const a = makeRecord("a");
|
||||
seed([a]);
|
||||
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
|
||||
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
|
||||
|
||||
const song = await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 7),
|
||||
originalName: "c.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
// The returned song must actually exist and be playable — not a phantom.
|
||||
expect(await p.getSongUrl(song.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMusicProvider filename handling", () => {
|
||||
it("accepts a long filename without dropping its extension", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
const longName = "x".repeat(300) + ".mp3";
|
||||
// Must not throw the "unsupported format" error — the extension survives.
|
||||
const song = await p.uploadAudio({
|
||||
buffer: Buffer.alloc(16, 1),
|
||||
originalName: longName,
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
expect(song.id).toBeTruthy();
|
||||
expect(await p.getSongUrl(song.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import type {
|
||||
Album,
|
||||
AuthStatus,
|
||||
LyricLine,
|
||||
MusicProvider,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
QrCodeResult,
|
||||
SearchResult,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
} from "./provider.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".webm",
|
||||
".wma",
|
||||
".alac",
|
||||
".aiff",
|
||||
".ape",
|
||||
]);
|
||||
|
||||
const DEFAULT_MAX_FILES = 200;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
|
||||
|
||||
export interface LocalMusicProviderOptions {
|
||||
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
|
||||
maxFiles?: number;
|
||||
/** Max total bytes of uploaded files kept on disk. */
|
||||
maxTotalBytes?: number;
|
||||
}
|
||||
|
||||
interface LocalSongRecord extends Song {
|
||||
filePath: string;
|
||||
originalName: string;
|
||||
uploadedAt: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function safeFileName(name: string): string {
|
||||
const base = path.basename(name || "audio")
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!base) return "audio";
|
||||
// Cap the total length but ALWAYS preserve the extension — truncating the
|
||||
// whole string would drop a trailing ".mp3" on a long filename and make the
|
||||
// file fail extension validation.
|
||||
const ext = path.extname(base);
|
||||
const stem = ext ? base.slice(0, base.length - ext.length) : base;
|
||||
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
|
||||
return `${safeStem}${ext}`;
|
||||
}
|
||||
|
||||
function titleFromFileName(name: string): string {
|
||||
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
|
||||
}
|
||||
|
||||
async function probeDurationSeconds(filePath: string): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
ffmpeg.kill("SIGKILL");
|
||||
resolve(0);
|
||||
}, 5000);
|
||||
ffmpeg.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
ffmpeg.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(0);
|
||||
});
|
||||
ffmpeg.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (!match) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const total = hours * 3600 + minutes * 60 + seconds;
|
||||
resolve(Number.isFinite(total) ? Math.round(total) : 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalMusicProvider implements MusicProvider {
|
||||
readonly platform = "local" as const;
|
||||
private readonly uploadDir: string;
|
||||
private readonly indexPath: string;
|
||||
private records: LocalSongRecord[] = [];
|
||||
private readonly maxFiles: number;
|
||||
private readonly maxTotalBytes: number;
|
||||
/** Ids that have been resolved for playback at least once; only these are
|
||||
* eligible for reference-aware cleanup, so freshly uploaded files that are
|
||||
* not yet queued/played survive in the search list. */
|
||||
private playedIds = new Set<string>();
|
||||
/** Returns the set of local song ids still referenced by any bot's queue.
|
||||
* Deletion never removes a file whose id this set contains. */
|
||||
private inUseResolver: () => Set<string> = () => new Set<string>();
|
||||
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
|
||||
* ffmpeg on Windows still releasing a just-stopped track). */
|
||||
private retrying = new Set<string>();
|
||||
|
||||
constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
|
||||
this.uploadDir = uploadDir;
|
||||
this.indexPath = path.join(uploadDir, "index.json");
|
||||
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
this.loadIndex();
|
||||
}
|
||||
|
||||
/** Wire the resolver the BotManager uses to report which uploads are still
|
||||
* queued anywhere. Must be set before any cleanup can delete files. */
|
||||
setInUseResolver(resolver: () => Set<string>): void {
|
||||
this.inUseResolver = resolver;
|
||||
}
|
||||
|
||||
private referencedIds(): Set<string> | null {
|
||||
try {
|
||||
return this.inUseResolver() ?? new Set<string>();
|
||||
} catch {
|
||||
// Resolver failure → references unknown → refuse to delete anything.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadIndex(): void {
|
||||
try {
|
||||
const raw = readFileSync(this.indexPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as LocalSongRecord[];
|
||||
this.records = Array.isArray(parsed)
|
||||
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
|
||||
: [];
|
||||
} catch {
|
||||
this.records = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveIndex(): void {
|
||||
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async uploadAudio(input: {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
mimeType?: string;
|
||||
}): Promise<Song> {
|
||||
const originalName = safeFileName(input.originalName || "audio");
|
||||
const ext = path.extname(originalName).toLowerCase();
|
||||
// Validate by the (sanitised) file extension only — never trust the
|
||||
// client-supplied Content-Type. This also guarantees the STORED extension
|
||||
// is one of the known audio types, so a spoofed header cannot persist an
|
||||
// arbitrary-extension blob on disk.
|
||||
if (!AUDIO_EXTENSIONS.has(ext)) {
|
||||
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
|
||||
}
|
||||
if (!input.buffer || input.buffer.length === 0) {
|
||||
throw new Error("上传文件为空");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const storedName = `${id}${ext}`;
|
||||
const filePath = path.join(this.uploadDir, storedName);
|
||||
writeFileSync(filePath, input.buffer);
|
||||
|
||||
const duration = await probeDurationSeconds(filePath);
|
||||
const song: LocalSongRecord = {
|
||||
id,
|
||||
name: titleFromFileName(originalName),
|
||||
artist: "本地上传",
|
||||
album: "本地音乐",
|
||||
duration,
|
||||
coverUrl: "",
|
||||
platform: "local",
|
||||
filePath,
|
||||
originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
size: input.buffer.length,
|
||||
mimeType: input.mimeType || "application/octet-stream",
|
||||
};
|
||||
|
||||
this.records.unshift(song);
|
||||
this.saveIndex();
|
||||
// Never evict the file we just accepted, even if every older file is still
|
||||
// queued — returning success for a file we deleted would be a phantom entry.
|
||||
this.enforceQuota(id);
|
||||
return this.toSong(song);
|
||||
}
|
||||
|
||||
private toSong(record: LocalSongRecord): Song {
|
||||
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
|
||||
return song;
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const q = query.trim().toLowerCase();
|
||||
const songs = this.records
|
||||
.filter((r) => existsSync(r.filePath))
|
||||
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
||||
.slice(0, limit)
|
||||
.map((r) => this.toSong(r));
|
||||
return { songs, playlists: [], albums: [] };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
|
||||
const record = this.records.find((r) => r.id === songId);
|
||||
if (!record || !existsSync(record.filePath)) return null;
|
||||
// A song that is actually resolved for playback becomes eligible for
|
||||
// cleanup once it is no longer referenced by any queue.
|
||||
this.playedIds.add(songId);
|
||||
return { url: record.filePath };
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const record = this.records.find((r) => r.id === songId);
|
||||
return record && existsSync(record.filePath) ? this.toSong(record) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-aware cleanup: delete only files that have been played at least
|
||||
* once AND are no longer referenced by any bot's queue. Safe to call after
|
||||
* any queue mutation — a file still queued anywhere (loop replay, prev,
|
||||
* the song being re-started, the same upload queued on another bot) is kept.
|
||||
* Returns the ids that were deleted.
|
||||
*/
|
||||
sweepUnreferenced(): string[] {
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse) return [];
|
||||
const deleted: string[] = [];
|
||||
for (let i = this.records.length - 1; i >= 0; i--) {
|
||||
const r = this.records[i];
|
||||
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
|
||||
if (this.unlinkRecordAt(i)) {
|
||||
deleted.push(r.id);
|
||||
} else {
|
||||
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
|
||||
// record and retry shortly; never orphan it or abort the rest.
|
||||
this.scheduleRetry(r.id);
|
||||
}
|
||||
}
|
||||
if (deleted.length) this.saveIndex();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Evict oldest, never-referenced uploads until under the file-count and
|
||||
* total-byte caps. Bounds disk use from uploads that are never played.
|
||||
* `protectId` is never evicted (the file just uploaded in this same call). */
|
||||
private enforceQuota(protectId?: string): void {
|
||||
if (this.records.length <= this.maxFiles &&
|
||||
this.totalBytes() <= this.maxTotalBytes) {
|
||||
return;
|
||||
}
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse) return; // can't safely evict without knowing references
|
||||
let count = this.records.length;
|
||||
let bytes = this.totalBytes();
|
||||
let changed = false;
|
||||
for (let i = this.records.length - 1;
|
||||
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
|
||||
i--) {
|
||||
const r = this.records[i];
|
||||
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
|
||||
const size = r.size || 0;
|
||||
if (this.unlinkRecordAt(i)) {
|
||||
count--;
|
||||
bytes -= size;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.saveIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the backing file for records[index] and drop the record from memory.
|
||||
* Deletes the FILE FIRST, then mutates state only on success, so a failed
|
||||
* unlink leaves the record intact (file + index stay consistent) instead of
|
||||
* orphaning the file. Returns true if the file is gone (deleted or already
|
||||
* absent), false if it is still present (locked). Never throws; does NOT
|
||||
* persist the index — callers batch saveIndex().
|
||||
*/
|
||||
private unlinkRecordAt(index: number): boolean {
|
||||
const r = this.records[index];
|
||||
try {
|
||||
rmSync(r.filePath, { force: true });
|
||||
} catch {
|
||||
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
|
||||
// the file genuinely vanished anyway, fall through and drop the record.
|
||||
if (existsSync(r.filePath)) return false;
|
||||
}
|
||||
this.records.splice(index, 1);
|
||||
this.playedIds.delete(r.id);
|
||||
this.retrying.delete(r.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
|
||||
* Uses unref'd timers so it never keeps the process alive. */
|
||||
private scheduleRetry(id: string, attempt = 1): void {
|
||||
if (attempt === 1 && this.retrying.has(id)) return;
|
||||
this.retrying.add(id);
|
||||
const MAX_ATTEMPTS = 6;
|
||||
const timer = setTimeout(() => {
|
||||
const index = this.records.findIndex((r) => r.id === id);
|
||||
if (index < 0) { this.retrying.delete(id); return; } // already removed
|
||||
const inUse = this.referencedIds();
|
||||
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
|
||||
if (this.unlinkRecordAt(index)) {
|
||||
this.saveIndex();
|
||||
} else if (attempt < MAX_ATTEMPTS) {
|
||||
this.scheduleRetry(id, attempt + 1);
|
||||
} else {
|
||||
this.retrying.delete(id); // give up; next sweep/quota will retry
|
||||
}
|
||||
}, 500 * attempt);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}
|
||||
|
||||
private totalBytes(): number {
|
||||
return this.records.reduce((n, r) => n + (r.size || 0), 0);
|
||||
}
|
||||
|
||||
setQuality(_quality: string): void {
|
||||
// 本地文件按原始音质播放。
|
||||
}
|
||||
|
||||
getQuality(): string {
|
||||
return "original";
|
||||
}
|
||||
|
||||
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAlbumSongs(_albumId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLyrics(_songId: string): Promise<LyricLine[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
throw new Error("Local music does not require login");
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
setCookie(_cookie: string): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return { loggedIn: true, nickname: "本地音乐" };
|
||||
}
|
||||
|
||||
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||
vip?: boolean;
|
||||
@@ -27,7 +27,7 @@ export interface Playlist {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
@@ -44,7 +44,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -72,7 +72,7 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||
|
||||
+5
-1
@@ -11,7 +11,8 @@ export function createAuthRouter(
|
||||
qqProvider: MusicProvider,
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger,
|
||||
cookieStore?: CookieStore
|
||||
cookieStore?: CookieStore,
|
||||
kugouProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
// 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 {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -65,6 +67,7 @@ export function createAuthRouter(
|
||||
if (status === "confirmed") {
|
||||
const cookie = provider.getCookie();
|
||||
const plat = (platform as string) === "bilibili" ? "bilibili" as const
|
||||
: (platform as string) === "kugou" ? "kugou" as const
|
||||
: (platform as string) === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookie && cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
@@ -137,6 +140,7 @@ export function createAuthRouter(
|
||||
const provider = getProvider(platform);
|
||||
provider.setCookie(cookie);
|
||||
const plat = platform === "bilibili" ? "bilibili" as const
|
||||
: platform === "kugou" ? "kugou" as const
|
||||
: platform === "qq" ? "qq" as const : "netease" as const;
|
||||
if (cookieStore) {
|
||||
cookieStore.save(plat, cookie);
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ export function createBotRouter(
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
localAudioEnabled: config.localAudioEnabled,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
@@ -44,7 +45,7 @@ export function createBotRouter(
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
|
||||
|
||||
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||
@@ -53,9 +54,11 @@ export function createBotRouter(
|
||||
}
|
||||
|
||||
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||
const hasLocalAudioEnabled = typeof localAudioEnabled === "boolean";
|
||||
|
||||
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
|
||||
|
||||
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
||||
if (hasGuestMode) {
|
||||
@@ -100,6 +103,7 @@ export function createBotRouter(
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
localAudioEnabled: config.localAudioEnabled,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
|
||||
+82
-3
@@ -1,25 +1,87 @@
|
||||
import { Router } from "express";
|
||||
import express, { Router } from "express";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
qqProvider: MusicProvider,
|
||||
bilibiliProvider: MusicProvider,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
localProvider?: MusicProvider,
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
|
||||
function isLocalAudioEnabled(): boolean {
|
||||
return config?.localAudioEnabled !== false;
|
||||
}
|
||||
|
||||
function getProvider(platform?: string): MusicProvider {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "local" && localProvider) return localProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/local/upload",
|
||||
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
|
||||
(_req, res, next) => {
|
||||
if (!isLocalAudioEnabled()) {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
express.raw({
|
||||
type: ["audio/*", "video/webm", "application/octet-stream"],
|
||||
limit: "200mb",
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (!localProvider) {
|
||||
res.status(501).json({ error: "Local upload is not configured" });
|
||||
return;
|
||||
}
|
||||
const uploadCapable = localProvider as MusicProvider & {
|
||||
uploadAudio?: (input: { buffer: Buffer; originalName: string; mimeType?: string }) => Promise<unknown>;
|
||||
};
|
||||
if (typeof uploadCapable.uploadAudio !== "function") {
|
||||
res.status(501).json({ error: "Local upload is not supported" });
|
||||
return;
|
||||
}
|
||||
if (!Buffer.isBuffer(req.body)) {
|
||||
res.status(400).json({ error: "raw audio body is required" });
|
||||
return;
|
||||
}
|
||||
const headerName = req.header("x-filename") || req.header("x-file-name") || "audio";
|
||||
let originalName = headerName;
|
||||
try {
|
||||
originalName = decodeURIComponent(headerName);
|
||||
} catch {
|
||||
// Keep the raw header value if it is not URI encoded.
|
||||
}
|
||||
const song = await uploadCapable.uploadAudio({
|
||||
buffer: req.body,
|
||||
originalName,
|
||||
mimeType: req.header("content-type") || undefined,
|
||||
});
|
||||
res.json({ song });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Local audio upload failed");
|
||||
res.status(400).json({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/search", async (req, res) => {
|
||||
try {
|
||||
const { q, platform, limit } = req.query;
|
||||
@@ -27,6 +89,10 @@ export function createMusicRouter(
|
||||
res.status(400).json({ error: "q (query) is required" });
|
||||
return;
|
||||
}
|
||||
if (platform === "local" && !isLocalAudioEnabled()) {
|
||||
res.json({ songs: [], playlists: [], albums: [] });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(platform as string);
|
||||
const result = await provider.search(
|
||||
q as string,
|
||||
@@ -47,16 +113,20 @@ export function createMusicRouter(
|
||||
return;
|
||||
}
|
||||
const parsedLimit = parseInt(limit as string) || 20;
|
||||
const [neteaseResult, qqResult, bilibiliResult] = await Promise.allSettled([
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
neteaseProvider.search(q as string, parsedLimit),
|
||||
qqProvider.search(q as string, parsedLimit),
|
||||
bilibiliProvider.search(q as string, parsedLimit),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
|
||||
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
@@ -76,6 +146,10 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/song/:id", async (req, res) => {
|
||||
try {
|
||||
if (req.query.platform === "local" && !isLocalAudioEnabled()) {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const song = await provider.getSongDetail(req.params.id);
|
||||
if (!song) {
|
||||
@@ -215,6 +289,8 @@ export function createMusicRouter(
|
||||
netease: neteaseProvider.getQuality(),
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
local: localProvider?.getQuality() ?? "original",
|
||||
kugou: kugouProvider?.getQuality() ?? "128",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +310,9 @@ export function createMusicRouter(
|
||||
if (!platform || platform === "bilibili") {
|
||||
bilibiliProvider.setQuality(quality);
|
||||
}
|
||||
if ((!platform || platform === "kugou") && kugouProvider) {
|
||||
kugouProvider.setQuality(quality);
|
||||
}
|
||||
logger.info({ quality, platform }, "Audio quality changed");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
+57
-4
@@ -39,9 +39,20 @@ export function createPlayerRouter(
|
||||
if (platform === "bilibili") return "-b";
|
||||
if (platform === "qq") return "-q";
|
||||
if (platform === "youtube") return "-y";
|
||||
if (platform === "kugou") return "-k";
|
||||
return "";
|
||||
};
|
||||
|
||||
function isLocalAudioDisabled(bot: any, platform: unknown): boolean {
|
||||
return platform === "local" &&
|
||||
typeof bot.isLocalAudioEnabled === "function" &&
|
||||
!bot.isLocalAudioEnabled();
|
||||
}
|
||||
|
||||
function rejectDisabledLocalAudio(res: any): void {
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
}
|
||||
|
||||
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
@@ -100,8 +111,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -269,10 +284,14 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -315,6 +334,10 @@ export function createPlayerRouter(
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
|
||||
// released and deleted, but an empty/failed playlist (early return above)
|
||||
// leaves the previous queue — and its files — intact.
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
// Use queue.play() for sequential, or pick random index for random modes
|
||||
const mode = queue.getMode();
|
||||
@@ -356,8 +379,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { albumId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -393,6 +420,8 @@ export function createPlayerRouter(
|
||||
for (const song of queueable) {
|
||||
queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
|
||||
const mode = queue.getMode();
|
||||
let first;
|
||||
@@ -432,13 +461,21 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const queue = bot.getQueueManager();
|
||||
bot.getPlayer().stop();
|
||||
queue.clear();
|
||||
queue.add(song);
|
||||
queue.play();
|
||||
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
// Sweep AFTER the new song is queued+resolved, so replaying a local song
|
||||
// that was still in the queue doesn't delete the file we're about to play.
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
if (!ok) {
|
||||
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
||||
return;
|
||||
@@ -460,6 +497,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + playback so concurrent requests can't
|
||||
// interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -504,6 +545,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the insert-after-current + promote + playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -533,6 +578,10 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
if (isLocalAudioDisabled(bot, song.platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + (possible) playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
@@ -561,8 +610,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
if (isLocalAudioDisabled(bot, platform)) {
|
||||
rejectDisabledLocalAudio(res);
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
+4
-2
@@ -38,6 +38,8 @@ export interface WebServerOptions {
|
||||
neteaseProvider: MusicProvider;
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
localProvider: MusicProvider;
|
||||
kugouProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
@@ -123,7 +125,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
@@ -131,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
));
|
||||
app.use(
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider)
|
||||
);
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||
|
||||
|
||||
+22
-10
@@ -44,11 +44,15 @@
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="mobileVolume"
|
||||
:value="mobileVolumeDisplay"
|
||||
class="m-volume-slider"
|
||||
@input="onMobileVolumeChange"
|
||||
@input="onMobileVolumeInput"
|
||||
@change="onMobileVolumeCommit"
|
||||
@pointerup="onMobileVolumeRelease"
|
||||
@pointercancel="onMobileVolumeRelease"
|
||||
@blur="onMobileVolumeRelease"
|
||||
/>
|
||||
<span class="m-volume-value">{{ mobileVolume }}</span>
|
||||
<span class="m-volume-value">{{ mobileVolumeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +83,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from './stores/player.js';
|
||||
import { useDecoupledSlider } from './composables/useDecoupledSlider.js';
|
||||
import { useWebSocket } from './composables/useWebSocket.js';
|
||||
import { useSession } from './composables/useSession.js';
|
||||
import Navbar from './components/Navbar.vue';
|
||||
@@ -99,7 +104,17 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { connect } = useWebSocket();
|
||||
const currentSong = computed(() => playerStore.currentSong);
|
||||
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
|
||||
// Volume slider decoupled from the 60fps updateMobileProgress() rAF re-render
|
||||
// so it isn't reset mid-drag (#111 — same root cause as the desktop player).
|
||||
const {
|
||||
display: mobileVolumeDisplay,
|
||||
onInput: onMobileVolumeInput,
|
||||
onChange: onMobileVolumeCommit,
|
||||
onRelease: onMobileVolumeRelease,
|
||||
} = useDecoupledSlider(
|
||||
() => playerStore.activeBot?.volume,
|
||||
(v) => playerStore.setVolume(v)
|
||||
);
|
||||
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
|
||||
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
|
||||
const mobileModeIcons: Record<string, string> = {
|
||||
@@ -118,17 +133,14 @@ let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
||||
// leave the mobile bar frozen between server pushes (#107).
|
||||
mobileProgressPct.value = duration > 0
|
||||
? Math.min((playerStore.elapsed / duration) * 100, 100)
|
||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||
: 0;
|
||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||
}
|
||||
|
||||
function onMobileVolumeChange(e: Event) {
|
||||
const volume = Number((e.target as HTMLInputElement).value);
|
||||
playerStore.setVolume(volume);
|
||||
}
|
||||
|
||||
function toggleMobileVolume() {
|
||||
mobileVolumeOpen.value = !mobileVolumeOpen.value;
|
||||
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
|
||||
|
||||
@@ -65,8 +65,12 @@
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="activeBot?.volume ?? 75"
|
||||
:value="volumeDisplay"
|
||||
@input="onVolumeInput"
|
||||
@change="onVolumeChange"
|
||||
@pointerup="onVolumeRelease"
|
||||
@pointercancel="onVolumeRelease"
|
||||
@blur="onVolumeRelease"
|
||||
class="volume-slider"
|
||||
/>
|
||||
</template>
|
||||
@@ -87,6 +91,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import { useDecoupledSlider } from '../composables/useDecoupledSlider.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Queue from './Queue.vue';
|
||||
|
||||
@@ -135,8 +140,9 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
// Use store.elapsed which interpolates from server ground truth
|
||||
currentElapsed.value = store.elapsed;
|
||||
// liveElapsed() (an action, not the cached `elapsed` getter) re-interpolates
|
||||
// from the server anchor on every frame so the clock ticks each second (#107).
|
||||
currentElapsed.value = store.liveElapsed();
|
||||
|
||||
const duration = currentSong.value?.duration ?? 0;
|
||||
progressPercent.value = duration > 0
|
||||
@@ -184,10 +190,17 @@ function togglePlay() {
|
||||
}
|
||||
}
|
||||
|
||||
function onVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
store.setVolume(parseInt(target.value));
|
||||
}
|
||||
// Volume slider is decoupled from the per-frame rAF re-render so dragging the
|
||||
// thumb isn't reset every frame (#111). See useDecoupledSlider.
|
||||
const {
|
||||
display: volumeDisplay,
|
||||
onInput: onVolumeInput,
|
||||
onChange: onVolumeChange,
|
||||
onRelease: onVolumeRelease,
|
||||
} = useDecoupledSlider(
|
||||
() => activeBot.value?.volume,
|
||||
(v) => store.setVolume(v)
|
||||
);
|
||||
|
||||
const modeOrder = ['seq', 'loop', 'random', 'rloop'] as const;
|
||||
const modeIcons: Record<string, string> = {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -135,6 +135,16 @@ function formatDuration(seconds: number): string {
|
||||
color: var(--brand-youtube);
|
||||
}
|
||||
|
||||
.badge-local {
|
||||
background: var(--color-primary-10);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-kugou {
|
||||
background: var(--brand-kugou-12);
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { Source } from '../stores/player.js';
|
||||
const LABELS: Record<Source, string> = {
|
||||
netease: '网易云',
|
||||
qq: 'QQ',
|
||||
kugou: '酷狗',
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ref, nextTick } from "vue";
|
||||
import { useDecoupledSlider } from "./useDecoupledSlider.js";
|
||||
|
||||
/** Minimal stand-in for an <input type="range"> change/input Event. */
|
||||
function ev(value: number): Event {
|
||||
return { target: { value: String(value) } } as unknown as Event;
|
||||
}
|
||||
|
||||
describe("useDecoupledSlider (#111)", () => {
|
||||
it("initialises display from the source, falling back when undefined", () => {
|
||||
const src = ref<number | undefined>(40);
|
||||
const { display } = useDecoupledSlider(() => src.value, () => {});
|
||||
expect(display.value).toBe(40);
|
||||
|
||||
const empty = useDecoupledSlider(() => undefined, () => {}, 75);
|
||||
expect(empty.display.value).toBe(75);
|
||||
});
|
||||
|
||||
it("reflects external source changes into the display when not dragging", async () => {
|
||||
const src = ref<number | undefined>(50);
|
||||
const { display } = useDecoupledSlider(() => src.value, () => {});
|
||||
src.value = 80;
|
||||
await nextTick();
|
||||
expect(display.value).toBe(80);
|
||||
});
|
||||
|
||||
it("tracks @input locally without committing", () => {
|
||||
const commit = vi.fn();
|
||||
const { display, onInput } = useDecoupledSlider(() => 50, commit);
|
||||
onInput(ev(63));
|
||||
expect(display.value).toBe(63);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// THE REGRESSION: this is exactly what the 60fps rAF re-render did — push the
|
||||
// (stale) source value back into the binding mid-drag. The guard must ignore
|
||||
// it so the thumb stays where the user dragged it.
|
||||
it("ignores external source changes WHILE dragging (no snap-back)", async () => {
|
||||
const src = ref<number | undefined>(50);
|
||||
const { display, onInput } = useDecoupledSlider(() => src.value, () => {});
|
||||
|
||||
onInput(ev(70)); // user starts dragging → display 70
|
||||
expect(display.value).toBe(70);
|
||||
|
||||
// Simulate the per-frame re-render re-evaluating the (still-stale) source.
|
||||
src.value = 50;
|
||||
await nextTick();
|
||||
expect(display.value).toBe(70); // stayed put — did NOT snap back to 50
|
||||
});
|
||||
|
||||
// Corner case: a range input skips `change` when released back at its start
|
||||
// value. onRelease (pointerup/pointercancel/blur) must still end the drag so
|
||||
// the slider doesn't freeze against later external updates.
|
||||
it("clears dragging on release even when @change never fires", async () => {
|
||||
const src = ref<number | undefined>(50);
|
||||
const { display, onInput, onRelease } = useDecoupledSlider(() => src.value, () => {});
|
||||
|
||||
onInput(ev(70)); // drag begins
|
||||
onInput(ev(50)); // ...dragged back to the start value
|
||||
onRelease(); // released — browser emits NO change event here
|
||||
|
||||
src.value = 30; // a later external update
|
||||
await nextTick();
|
||||
expect(display.value).toBe(30); // slider resumed following the source
|
||||
});
|
||||
|
||||
it("commits on @change and resumes following the source afterwards", async () => {
|
||||
const commit = vi.fn();
|
||||
const src = ref<number | undefined>(50);
|
||||
const { display, onInput, onChange } = useDecoupledSlider(() => src.value, commit);
|
||||
|
||||
onInput(ev(70));
|
||||
onChange(ev(70)); // release
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(commit).toHaveBeenCalledWith(70);
|
||||
expect(display.value).toBe(70);
|
||||
|
||||
// After release, external changes flow through again.
|
||||
src.value = 35;
|
||||
await nextTick();
|
||||
expect(display.value).toBe(35);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* A slider whose displayed value is decoupled from its reactive source.
|
||||
*
|
||||
* Why this exists (#111): the player components run a 60fps requestAnimationFrame
|
||||
* loop (progress clock, #107) that re-renders the whole component every ~16ms.
|
||||
* Binding a range `<input :value>` straight to a reactive source (the store
|
||||
* volume) let Vue re-apply `el.value = source` on every one of those re-renders.
|
||||
* Mid-drag the source is still the *old* value, so the native drag position kept
|
||||
* getting snapped back — the thumb was effectively un-draggable on desktop and
|
||||
* janky on mobile.
|
||||
*
|
||||
* The fix is to bind `:value` to a LOCAL ref that:
|
||||
* - tracks the native drag synchronously via `@input` (so the bound value always
|
||||
* equals the element's value → Vue never resets it), and
|
||||
* - is committed to the real source only on `@change` (release).
|
||||
* External source changes (bot switch, another client) still flow into the
|
||||
* display — except while the user is actively dragging, where they must be
|
||||
* ignored or they'd fight the drag.
|
||||
*
|
||||
* @param source getter for the authoritative value (e.g. () => bot?.volume)
|
||||
* @param commit called with the final value on release (e.g. store.setVolume)
|
||||
* @param fallback value to show when the source is undefined (default 75)
|
||||
*/
|
||||
export function useDecoupledSlider(
|
||||
source: () => number | undefined,
|
||||
commit: (value: number) => void,
|
||||
fallback = 75
|
||||
): {
|
||||
display: Ref<number>;
|
||||
dragging: Ref<boolean>;
|
||||
onInput: (e: Event) => void;
|
||||
onChange: (e: Event) => void;
|
||||
onRelease: () => void;
|
||||
} {
|
||||
const display = ref<number>(source() ?? fallback);
|
||||
const dragging = ref(false);
|
||||
|
||||
watch(source, (v) => {
|
||||
// Reflect external/store changes — but never while dragging, or the
|
||||
// per-frame re-render would yank the thumb away from the user's finger.
|
||||
if (!dragging.value && typeof v === 'number') display.value = v;
|
||||
});
|
||||
|
||||
function onInput(e: Event): void {
|
||||
dragging.value = true;
|
||||
display.value = Number((e.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function onChange(e: Event): void {
|
||||
dragging.value = false;
|
||||
const v = Number((e.target as HTMLInputElement).value);
|
||||
display.value = v;
|
||||
commit(v);
|
||||
}
|
||||
|
||||
function onRelease(): void {
|
||||
// Safety net for pointerup / pointercancel / blur: a range input does NOT
|
||||
// emit `change` if the value is released back at its starting point, which
|
||||
// would otherwise leave `dragging` stuck true and freeze the slider against
|
||||
// later external updates. Clearing here is idempotent with onChange.
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
return { display, dragging, onInput, onChange, onRelease };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { interpolateElapsed, type TimingState } from "./player.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function timing(partial: Partial<TimingState>): TimingState {
|
||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false, ...partial };
|
||||
}
|
||||
|
||||
describe("interpolateElapsed", () => {
|
||||
it("returns serverElapsed before playback has a sync anchor", () => {
|
||||
expect(interpolateElapsed(timing({ serverElapsed: 12, wasPlaying: false }), false, Infinity)).toBe(12);
|
||||
// wasPlaying but no sync time yet
|
||||
expect(interpolateElapsed(timing({ serverElapsed: 5, wasPlaying: true, serverSyncTime: 0 }), false, Infinity)).toBe(5);
|
||||
});
|
||||
|
||||
it("advances with wall-clock time while playing (regression: must not be frozen)", () => {
|
||||
const spy = vi.spyOn(Date, "now");
|
||||
const t = timing({ serverElapsed: 30, serverSyncTime: 10_000, wasPlaying: true });
|
||||
|
||||
spy.mockReturnValue(10_000);
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(30, 5);
|
||||
|
||||
spy.mockReturnValue(11_000); // +1s
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(31, 5);
|
||||
|
||||
spy.mockReturnValue(13_500); // +3.5s — distinct from the 1s reading
|
||||
expect(interpolateElapsed(t, false, Infinity)).toBeCloseTo(33.5, 5);
|
||||
});
|
||||
|
||||
it("freezes at serverElapsed while paused", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(99_000);
|
||||
const t = timing({ serverElapsed: 42, serverSyncTime: 10_000, wasPlaying: true });
|
||||
expect(interpolateElapsed(t, true, Infinity)).toBe(42);
|
||||
});
|
||||
|
||||
it("clamps to maxDuration", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_000_000);
|
||||
const t = timing({ serverElapsed: 100, serverSyncTime: 1_000, wasPlaying: true });
|
||||
expect(interpolateElapsed(t, false, 180)).toBe(180);
|
||||
});
|
||||
});
|
||||
+93
-17
@@ -10,10 +10,10 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou';
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq';
|
||||
export type Source = 'netease' | 'qq' | 'kugou';
|
||||
|
||||
export interface BotStatus {
|
||||
id: string;
|
||||
@@ -47,7 +47,7 @@ export interface FavoritePlaylist {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
export interface TimingState {
|
||||
serverElapsed: number;
|
||||
serverSyncTime: number;
|
||||
wasPlaying: boolean;
|
||||
@@ -59,6 +59,33 @@ function defaultTiming(): TimingState {
|
||||
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate the live elapsed seconds from the last server anchor.
|
||||
*
|
||||
* This is a PURE function (its only time source is `Date.now()`), deliberately
|
||||
* kept OUT of the Pinia getter so it can be called fresh every animation frame.
|
||||
* The `elapsed` getter is a Vue `computed` and caches its result until a
|
||||
* REACTIVE dependency changes — but `Date.now()` is not reactive, so a getter
|
||||
* only re-runs on a WebSocket push / server poll (every few seconds). Reading
|
||||
* the getter from a requestAnimationFrame loop therefore returns a frozen value
|
||||
* and the clock appears to jump ~3s at a time (issue #107). Per-frame consumers
|
||||
* must call this helper (via the `liveElapsed` action) instead.
|
||||
*/
|
||||
export function interpolateElapsed(
|
||||
timing: TimingState,
|
||||
isPaused: boolean,
|
||||
maxDuration: number,
|
||||
): number {
|
||||
// No live anchor yet, or paused: report the frozen server position.
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0 || isPaused) {
|
||||
return Math.min(timing.serverElapsed, maxDuration);
|
||||
}
|
||||
return Math.min(
|
||||
timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000,
|
||||
maxDuration,
|
||||
);
|
||||
}
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
bots: [] as BotStatus[],
|
||||
@@ -73,11 +100,11 @@ export const usePlayerStore = defineStore('player', {
|
||||
theme: 'dark' as 'dark' | 'light',
|
||||
|
||||
// Home page cache, split by source
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
|
||||
bilibiliPopular: [] as Song[],
|
||||
authStatus: { netease: false, qq: false },
|
||||
authStatus: { netease: false, qq: false, kugou: false },
|
||||
lastFetchTime: 0,
|
||||
|
||||
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||
@@ -111,26 +138,46 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!botId) return [];
|
||||
return this.queues[botId] ?? [];
|
||||
},
|
||||
/** Interpolated elapsed for the active bot */
|
||||
/**
|
||||
* Interpolated elapsed for the active bot. NOTE: as a Pinia getter this is
|
||||
* a Vue `computed` and is CACHED — it only re-runs when a reactive
|
||||
* dependency changes, so it does NOT tick every second on its own. Use it
|
||||
* for one-off reactive reads; per-frame consumers (progress bar, lyrics)
|
||||
* must call the `liveElapsed` action so the clock advances smoothly (#107).
|
||||
*/
|
||||
elapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
||||
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
||||
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
/** Sources that are currently logged in. Order: netease before qq. */
|
||||
availableSources(): Source[] {
|
||||
const s: Source[] = [];
|
||||
if (this.authStatus.netease) s.push('netease');
|
||||
if (this.authStatus.qq) s.push('qq');
|
||||
if (this.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Live elapsed seconds for the active bot, recomputed on every call. Unlike
|
||||
* the `elapsed` getter (a cached computed), this is an action, so it is NOT
|
||||
* memoised — call it from requestAnimationFrame / interval loops so the
|
||||
* progress bar and lyrics advance every frame instead of jumping on each
|
||||
* server push (#107).
|
||||
*/
|
||||
liveElapsed(): number {
|
||||
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||
const timing = this.timings[botId] ?? defaultTiming();
|
||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||
},
|
||||
|
||||
_getTiming(botId: string): TimingState {
|
||||
if (!this.timings[botId]) {
|
||||
this.timings[botId] = defaultTiming();
|
||||
@@ -403,9 +450,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async pause() {
|
||||
if (!this.activeBotId) return;
|
||||
// Freeze elapsed at current interpolated value
|
||||
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
||||
// `elapsed` getter here could snapshot a value up to a few seconds stale.
|
||||
this._setTiming(this.activeBotId, {
|
||||
serverElapsed: this.elapsed,
|
||||
serverElapsed: this.liveElapsed(),
|
||||
wasPlaying: false,
|
||||
});
|
||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||
@@ -524,18 +572,23 @@ export const usePlayerStore = defineStore('player', {
|
||||
// Always check auth status first — if it changed since the cached
|
||||
// fetch (e.g., user logged in/out as a different account), the
|
||||
// cached playlists belong to a different user and we MUST refetch.
|
||||
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
||||
const [neAuthRes, qqAuthRes, kugouAuthRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
]);
|
||||
const newAuth = {
|
||||
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
||||
};
|
||||
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.qq = newAuth.qq;
|
||||
this.authStatus.kugou = newAuth.kugou;
|
||||
|
||||
// Favorites are user-local and cheap; always refresh them, even on a
|
||||
// home-data cache hit, so hearts stay correct across tabs/sessions.
|
||||
@@ -574,15 +627,30 @@ export const usePlayerStore = defineStore('player', {
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
||||
// so gate all three on auth like QQ.
|
||||
const kugouPromises = this.authStatus.kugou
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'kugou' } }),
|
||||
]
|
||||
: [
|
||||
Promise.resolve(emptyPlaylists),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...neteasePromises,
|
||||
...qqPromises,
|
||||
...kugouPromises,
|
||||
biliPromise,
|
||||
]);
|
||||
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
|
||||
|
||||
this.recommendPlaylists.netease =
|
||||
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
||||
@@ -596,6 +664,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
|
||||
this.userPlaylists.qq =
|
||||
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : [];
|
||||
this.recommendPlaylists.kugou =
|
||||
kgRecPL.status === 'fulfilled' ? (kgRecPL.value.data.playlists ?? []) : [];
|
||||
this.dailySongs.kugou =
|
||||
kgDaily.status === 'fulfilled' ? (kgDaily.value.data.songs ?? []) : [];
|
||||
this.userPlaylists.kugou =
|
||||
kgUserPL.status === 'fulfilled' ? (kgUserPL.value.data.playlists ?? []) : [];
|
||||
// bilibili popular: keep previous value on failure (it's an anonymous endpoint
|
||||
// unrelated to user auth state, and stale popular results are harmless)
|
||||
if (bili.status === 'fulfilled') {
|
||||
@@ -607,7 +681,9 @@ export const usePlayerStore = defineStore('player', {
|
||||
// cached for 5 minutes, otherwise the user has to hard-reload to
|
||||
// recover when connectivity returns.
|
||||
const authOk =
|
||||
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled';
|
||||
neAuthRes.status === 'fulfilled' ||
|
||||
qqAuthRes.status === 'fulfilled' ||
|
||||
kugouAuthRes.status === 'fulfilled';
|
||||
if (authOk) {
|
||||
this.lastFetchTime = Date.now();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
|
||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||
const all = readAll();
|
||||
const v = all[key];
|
||||
return v === 'netease' || v === 'qq' ? v : fallback;
|
||||
return v === 'netease' || v === 'qq' || v === 'kugou' ? v : fallback;
|
||||
}
|
||||
|
||||
export function saveTabSource(key: TabKey, value: Source): void {
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
--brand-bilibili-15: rgba(0, 161, 214, 0.15);
|
||||
--brand-youtube: #ff0000;
|
||||
--brand-youtube-12: rgba(255, 0, 0, 0.12);
|
||||
--brand-kugou: #2ca2f9;
|
||||
--brand-kugou-12: rgba(44, 162, 249, 0.12);
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
|
||||
+18
-3
@@ -41,13 +41,23 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div class="fm-icon-wrapper kugou">
|
||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">酷狗私人电台</div>
|
||||
<div class="fm-desc">个性化推荐歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
每日推荐
|
||||
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
|
||||
<SourceTabs :model-value="dailySourceSafe" @update:model-value="dailySource = $event" :sources="dailyAvailable" />
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
@@ -67,7 +77,7 @@
|
||||
<section class="section" v-if="recommendAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
推荐歌单
|
||||
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
|
||||
<SourceTabs :model-value="recommendSourceSafe" @update:model-value="recommendSource = $event" :sources="recommendAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -108,7 +118,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
@@ -173,6 +183,7 @@ const userPlaylistsExpanded = ref(false);
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
if (store.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
||||
@@ -358,6 +369,10 @@ onMounted(() => {
|
||||
&.qq {
|
||||
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||
}
|
||||
|
||||
&.kugou {
|
||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
<SourceTabs :model-value="userSourceSafe" @update:model-value="userSource = $event" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
|
||||
@@ -136,7 +136,9 @@ function scrollToActiveLine(idx: number) {
|
||||
|
||||
function syncLyrics() {
|
||||
if (!store.isPlaying || lines.value.length === 0) return;
|
||||
const elapsed = store.elapsed;
|
||||
// liveElapsed() (action) is recomputed now; the cached `elapsed` getter only
|
||||
// refreshed on server pushes, leaving highlights ~half a line behind (#107).
|
||||
const elapsed = store.liveElapsed();
|
||||
const idx = findActiveLine(elapsed);
|
||||
// Only update when the active line actually changes
|
||||
if (idx !== activeLine.value && idx >= 0) {
|
||||
|
||||
+242
-7
@@ -16,6 +16,41 @@
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="localAudioEnabled"
|
||||
class="local-upload"
|
||||
:class="{ dragging: isDragging, uploading }"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
|
||||
<div class="upload-copy">
|
||||
<div class="upload-title">拖拽本地音频到这里上传</div>
|
||||
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
|
||||
</div>
|
||||
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
|
||||
{{ uploading ? '上传中...' : '选择音频' }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="local-upload disabled">
|
||||
<Icon icon="mdi:music-off" class="upload-icon" />
|
||||
<div class="upload-copy">
|
||||
<div class="upload-title">本地音频播放已关闭</div>
|
||||
<div class="upload-subtitle">管理员可在「设置 → 行为设置 → 本地音频播放」中开启。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadMessage" class="upload-message" :class="uploadMessageType">{{ uploadMessage }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
@@ -37,6 +72,17 @@
|
||||
:class="{ active: selectedSource === 'bilibili' }"
|
||||
@click="selectedSource = 'bilibili'"
|
||||
>B站</button>
|
||||
<button
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'kugou' }"
|
||||
@click="selectedSource = 'kugou'"
|
||||
>酷狗</button>
|
||||
<button
|
||||
v-if="hasLocalSongs"
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'local' }"
|
||||
@click="selectedSource = 'local'"
|
||||
>本地</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
@@ -48,7 +94,7 @@
|
||||
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'albums' }"
|
||||
@click="activeTab = 'albums'"
|
||||
@@ -56,7 +102,7 @@
|
||||
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local' && selectedSource !== 'kugou'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'playlists' }"
|
||||
@click="activeTab = 'playlists'"
|
||||
@@ -141,17 +187,19 @@ const router = useRouter();
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'search-source';
|
||||
|
||||
function loadSource(): 'netease' | 'qq' | 'bilibili' {
|
||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
||||
|
||||
function loadSource(): SearchSource {
|
||||
try {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
|
||||
const selectedSource = ref<SearchSource>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
@@ -161,6 +209,12 @@ const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
const uploading = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const uploadMessage = ref('');
|
||||
const uploadMessageType = ref<'info' | 'error'>('info');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const localAudioEnabled = ref(true);
|
||||
|
||||
const filteredSongs = computed(() =>
|
||||
allSongs.value.filter((s) => s.platform === selectedSource.value)
|
||||
@@ -174,14 +228,16 @@ const filteredPlaylists = computed(() =>
|
||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// Persist source preference
|
||||
watch(selectedSource, (src) => {
|
||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// B站 has no albums/playlists — force songs tab when switching to B站
|
||||
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
|
||||
watch(selectedSource, (src) => {
|
||||
if (src === 'bilibili' && activeTab.value !== 'songs') {
|
||||
if ((src === 'bilibili' || src === 'local' || src === 'kugou') && activeTab.value !== 'songs') {
|
||||
activeTab.value = 'songs';
|
||||
}
|
||||
});
|
||||
@@ -223,10 +279,84 @@ async function doSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isAudioFile(file: File): boolean {
|
||||
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
|
||||
}
|
||||
|
||||
async function uploadLocalFiles(fileList: File[]) {
|
||||
if (!localAudioEnabled.value) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '本地音频播放已关闭';
|
||||
return;
|
||||
}
|
||||
const files = fileList.filter(isAudioFile);
|
||||
if (files.length === 0) {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = '没有找到可上传的音频文件';
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
uploadMessageType.value = 'info';
|
||||
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
|
||||
|
||||
const uploaded: Song[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const res = await axios.post('/api/music/local/upload', file, {
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
if (res.data?.song) uploaded.push(res.data.song as Song);
|
||||
} catch (err: any) {
|
||||
failed.push(`${file.name}: ${err?.response?.data?.error || '上传失败'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
const uploadedKeys = new Set(uploaded.map((s) => `${s.platform}-${s.id}`));
|
||||
allSongs.value = [
|
||||
...uploaded,
|
||||
...allSongs.value.filter((s) => !uploadedKeys.has(`${s.platform}-${s.id}`)),
|
||||
];
|
||||
selectedSource.value = 'local';
|
||||
activeTab.value = 'songs';
|
||||
searched.value = true;
|
||||
uploadMessageType.value = failed.length ? 'error' : 'info';
|
||||
uploadMessage.value = failed.length
|
||||
? `已上传 ${uploaded.length} 个,失败 ${failed.length} 个:${failed[0]}`
|
||||
: `已上传 ${uploaded.length} 个本地音频`;
|
||||
} else {
|
||||
uploadMessageType.value = 'error';
|
||||
uploadMessage.value = failed[0] || '上传失败';
|
||||
}
|
||||
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false;
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
uploadLocalFiles(files);
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadLocalFiles(Array.from(input.files ?? []));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function badgeLabel(platform: string): string {
|
||||
if (platform === 'qq') return 'QQ';
|
||||
if (platform === 'bilibili') return 'B站';
|
||||
if (platform === 'youtube') return 'YouTube';
|
||||
if (platform === 'local') return '本地';
|
||||
if (platform === 'kugou') return '酷狗';
|
||||
return '网易云';
|
||||
}
|
||||
|
||||
@@ -234,10 +364,25 @@ function badgeClass(platform: string): string {
|
||||
if (platform === 'qq') return 'badge-qq';
|
||||
if (platform === 'bilibili') return 'badge-bilibili';
|
||||
if (platform === 'youtube') return 'badge-youtube';
|
||||
if (platform === 'local') return 'badge-local';
|
||||
if (platform === 'kugou') return 'badge-kugou';
|
||||
return 'badge-netease';
|
||||
}
|
||||
|
||||
async function loadLocalAudioSetting() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||
if (!localAudioEnabled.value && selectedSource.value === 'local') {
|
||||
selectedSource.value = 'netease';
|
||||
}
|
||||
} catch {
|
||||
// Guests may not be allowed to read settings; backend still enforces the switch.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLocalAudioSetting();
|
||||
if (query.value) doSearch();
|
||||
});
|
||||
</script>
|
||||
@@ -258,6 +403,86 @@ onMounted(() => {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.local-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
|
||||
|
||||
&.dragging {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 28px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.upload-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: var(--fw-semi);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-subtitle {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: var(--fw-semi);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.local-upload.disabled {
|
||||
opacity: 0.65;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.upload-message {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -422,6 +647,16 @@ onMounted(() => {
|
||||
color: var(--brand-youtube);
|
||||
}
|
||||
|
||||
.badge-local {
|
||||
background: var(--color-primary-10);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-kugou {
|
||||
background: var(--brand-kugou-12);
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
+117
-6
@@ -377,6 +377,76 @@
|
||||
<button class="btn-primary btn-save" @click="saveCookie('bilibili')">保存Cookie</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kugou -->
|
||||
<div class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
|
||||
<div class="account-info">
|
||||
<div class="account-name">酷狗音乐</div>
|
||||
<div class="account-status" :class="{ logged: kugouAuth.loggedIn }">
|
||||
{{ kugouAuth.loggedIn ? `已登录: ${kugouAuth.nickname}` : '未登录' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-methods">
|
||||
<button
|
||||
class="login-btn"
|
||||
:class="{ active: kugouLoginMode === 'qr' }"
|
||||
@click="startQrLogin('kugou')"
|
||||
:disabled="kugouQr.loading"
|
||||
>
|
||||
<Icon icon="mdi:qrcode" />
|
||||
扫码登录
|
||||
</button>
|
||||
<button
|
||||
class="login-btn"
|
||||
:class="{ active: kugouLoginMode === 'cookie' }"
|
||||
@click="kugouLoginMode = 'cookie'"
|
||||
>
|
||||
<Icon icon="mdi:cookie" />
|
||||
Cookie登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="kugouLoginMode === 'qr'" class="qr-section">
|
||||
<div v-if="kugouQr.loading" class="qr-loading">
|
||||
<Icon icon="mdi:loading" class="spin" />
|
||||
生成二维码中...
|
||||
</div>
|
||||
<div v-else-if="kugouQr.dataUrl" class="qr-wrap">
|
||||
<img :src="kugouQr.dataUrl" class="qr-image" alt="QR Code" />
|
||||
<div class="qr-status" :class="kugouQr.status">
|
||||
<template v-if="kugouQr.status === 'waiting'">
|
||||
<Icon icon="mdi:cellphone" /> 请使用酷狗音乐APP扫码
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'scanned'">
|
||||
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'confirmed'">
|
||||
<Icon icon="mdi:check-circle" /> 登录成功!
|
||||
</template>
|
||||
<template v-else-if="kugouQr.status === 'expired'">
|
||||
<Icon icon="mdi:refresh" /> 二维码已过期
|
||||
<button class="btn-link" @click="startQrLogin('kugou')">重新生成</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie -->
|
||||
<div v-if="kugouLoginMode === 'cookie'" class="cookie-section">
|
||||
<textarea
|
||||
v-model="kugouCookie"
|
||||
class="textarea"
|
||||
placeholder="粘贴酷狗Cookie (token=...; userid=...)..."
|
||||
rows="3"
|
||||
/>
|
||||
<button class="btn-primary btn-save" @click="saveCookie('kugou')">保存Cookie</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio Quality requires quality -->
|
||||
@@ -453,6 +523,19 @@
|
||||
@change="saveAutoPause"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="profile-toggle behavior-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">本地音频播放</div>
|
||||
<div class="profile-toggle-hint">开启后允许在搜索页拖拽/选择本地音频上传并播放;关闭后会拒绝新的本地上传和本地歌曲播放请求。</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="localAudioEnabled"
|
||||
type="checkbox"
|
||||
class="profile-toggle-switch"
|
||||
@change="saveLocalAudioEnabled"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Guest Mode (admin only) -->
|
||||
@@ -779,6 +862,7 @@ const editForm = reactive({
|
||||
const neteaseCookie = ref('');
|
||||
const qqCookie = ref('');
|
||||
const bilibiliCookie = ref('');
|
||||
const kugouCookie = ref('');
|
||||
const commandPrefix = ref('!');
|
||||
|
||||
// Audio quality
|
||||
@@ -810,11 +894,13 @@ async function setQuality(q: string) {
|
||||
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const bilibiliLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
const kugouLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||
|
||||
// Auth status
|
||||
const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||
|
||||
// QR state
|
||||
interface QrState {
|
||||
@@ -834,22 +920,28 @@ const qqQr = reactive<QrState>({
|
||||
const bilibiliQr = reactive<QrState>({
|
||||
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
|
||||
});
|
||||
const kugouQr = reactive<QrState>({
|
||||
loading: false, dataUrl: '', key: '', status: 'waiting', pollTimer: null,
|
||||
});
|
||||
|
||||
function getQrState(platform: string): QrState {
|
||||
if (platform === 'bilibili') return bilibiliQr;
|
||||
if (platform === 'kugou') return kugouQr;
|
||||
return platform === 'netease' ? neteaseQr : qqQr;
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const [nRes, qRes, bRes] = await Promise.all([
|
||||
const [nRes, qRes, bRes, kRes] = await Promise.all([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||
]);
|
||||
Object.assign(neteaseAuth, nRes.data);
|
||||
Object.assign(qqAuth, qRes.data);
|
||||
Object.assign(bilibiliAuth, bRes.data);
|
||||
Object.assign(kugouAuth, kRes.data);
|
||||
} catch {
|
||||
// API not ready
|
||||
}
|
||||
@@ -859,6 +951,7 @@ async function startQrLogin(platform: string) {
|
||||
const qr = getQrState(platform);
|
||||
if (platform === 'netease') neteaseLoginMode.value = 'qr';
|
||||
else if (platform === 'bilibili') bilibiliLoginMode.value = 'qr';
|
||||
else if (platform === 'kugou') kugouLoginMode.value = 'qr';
|
||||
else qqLoginMode.value = 'qr';
|
||||
|
||||
// Stop existing poll
|
||||
@@ -876,13 +969,15 @@ async function startQrLogin(platform: string) {
|
||||
if (qrImg) {
|
||||
qr.dataUrl = qrImg;
|
||||
} else {
|
||||
// QR codes must be dark-on-light to stay scannable. Do NOT invert the
|
||||
// colours for the dark theme: many in-app scanners (notably the Kugou
|
||||
// music app) cannot decode a light-on-dark QR, so a themed code looks
|
||||
// fine on screen but silently fails to scan. The white quiet-zone frames
|
||||
// it cleanly in dark mode anyway.
|
||||
qr.dataUrl = await QRCode.toDataURL(qrUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: store.theme === 'dark' ? '#ffffff' : '#000000',
|
||||
light: store.theme === 'dark' ? '#2a2a2a' : '#ffffff',
|
||||
},
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1019,7 +1114,9 @@ async function toggleBot(botId: string, connected: boolean) {
|
||||
}
|
||||
|
||||
async function saveCookie(platform: string) {
|
||||
const cookie = platform === 'bilibili' ? bilibiliCookie.value : platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||
const cookie = platform === 'bilibili' ? bilibiliCookie.value
|
||||
: platform === 'kugou' ? kugouCookie.value
|
||||
: platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||
if (!cookie) return;
|
||||
try {
|
||||
await axios.post('/api/auth/cookie', { platform, cookie });
|
||||
@@ -1037,12 +1134,14 @@ async function savePrefix() {
|
||||
const idleTimeout = ref(0);
|
||||
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
||||
const autoPauseOnEmpty = ref(false);
|
||||
const localAudioEnabled = ref(true);
|
||||
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||
} catch { /* ignore */ }
|
||||
@@ -1060,6 +1159,13 @@ async function saveAutoPause() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveLocalAudioEnabled() {
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
|
||||
localAudioEnabled.value = res.data.localAudioEnabled ?? localAudioEnabled.value;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Guest mode (admin only) ---
|
||||
const GUEST_FLAGS: { token: string; label: string }[] = [
|
||||
{ token: 'addToQueue', label: '添加到队列末尾' },
|
||||
@@ -1535,6 +1641,7 @@ onUnmounted(() => {
|
||||
if (neteaseQr.pollTimer) clearInterval(neteaseQr.pollTimer);
|
||||
if (qqQr.pollTimer) clearInterval(qqQr.pollTimer);
|
||||
if (bilibiliQr.pollTimer) clearInterval(bilibiliQr.pollTimer);
|
||||
if (kugouQr.pollTimer) clearInterval(kugouQr.pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1676,6 +1783,10 @@ onUnmounted(() => {
|
||||
&.bilibili-icon {
|
||||
color: var(--brand-bilibili);
|
||||
}
|
||||
|
||||
&.kugou-icon {
|
||||
color: var(--brand-kugou);
|
||||
}
|
||||
}
|
||||
|
||||
.account-name {
|
||||
|
||||
Reference in new issue
Block a user