mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f09694a3 | ||
|
|
6d56f1f371 | ||
|
|
64328d7bdf | ||
|
|
287dd240b1 | ||
|
|
22ec7328fa | ||
|
|
540641700d |
No files matched your search
@@ -23,14 +23,17 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||||
|
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
|
||||||
|
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||||
|
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(可在设置中关闭)
|
||||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部
|
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
|
||||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||||
@@ -274,18 +277,18 @@ sudo systemctl start tsmusicbot
|
|||||||
- 机器人昵称
|
- 机器人昵称
|
||||||
- 可选:服务器密码、默认频道
|
- 可选:服务器密码、默认频道
|
||||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
|
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
|
||||||
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员,成员账号可以控制播放但无法管理其他用户
|
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
||||||
|
|
||||||
### WebUI 页面说明
|
### WebUI 页面说明
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
|
||||||
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
|
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
|
||||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
|
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
||||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||||
| **历史** | 播放历史记录 |
|
| **历史** | 播放历史记录 |
|
||||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) |
|
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||||
|
|
||||||
### TeamSpeak 文字命令
|
### TeamSpeak 文字命令
|
||||||
|
|
||||||
@@ -293,11 +296,14 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `!play <歌名>` | 搜索并播放 |
|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
|
||||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||||
| `!add <歌名>` | 添加到播放队列 |
|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
|
||||||
|
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||||
|
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
||||||
|
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||||
| `!stop` | 停止播放并清空队列 |
|
| `!stop` | 停止播放并清空队列 |
|
||||||
@@ -310,6 +316,7 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!album <ID>` | 加载专辑 |
|
| `!album <ID>` | 加载专辑 |
|
||||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
||||||
| `!fm` | 私人 FM(网易云,自动续播) |
|
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||||
|
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||||
| `!lyrics` | 显示当前歌词 |
|
| `!lyrics` | 显示当前歌词 |
|
||||||
| `!now` | 当前播放信息 |
|
| `!now` | 当前播放信息 |
|
||||||
| `!vote` | 投票跳过当前歌曲 |
|
| `!vote` | 投票跳过当前歌曲 |
|
||||||
@@ -344,10 +351,12 @@ teamspeak-music-bot/
|
|||||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||||
│ │ ├── manager.ts # 多实例生命周期管理
|
│ │ ├── manager.ts # 多实例生命周期管理
|
||||||
|
│ │ ├── auto-pause.ts # 频道无人自动暂停/恢复的决策逻辑
|
||||||
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
||||||
│ ├── data/ # 数据层
|
│ ├── data/ # 数据层
|
||||||
│ │ ├── config.ts # JSON 配置文件
|
│ │ ├── config.ts # JSON 配置文件(持久化到 data/config.json)
|
||||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
│ │ ├── permissions.ts # 细粒度能力 + 按机器人授权白名单
|
||||||
|
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
|
||||||
│ ├── music/ # 音源服务
|
│ ├── music/ # 音源服务
|
||||||
│ │ ├── provider.ts # 统一 MusicProvider 接口
|
│ │ ├── provider.ts # 统一 MusicProvider 接口
|
||||||
│ │ ├── netease.ts # 网易云音乐适配器
|
│ │ ├── netease.ts # 网易云音乐适配器
|
||||||
@@ -364,10 +373,13 @@ teamspeak-music-bot/
|
|||||||
│ ├── web/ # Web 后端
|
│ ├── web/ # Web 后端
|
||||||
│ │ ├── server.ts # Express + WebSocket 服务
|
│ │ ├── server.ts # Express + WebSocket 服务
|
||||||
│ │ ├── websocket.ts # 实时状态广播
|
│ │ ├── websocket.ts # 实时状态广播
|
||||||
|
│ │ ├── middleware/ # requireAuth / requireAdmin / requirePermission / CSRF
|
||||||
│ │ └── api/ # REST API 路由
|
│ │ └── api/ # REST API 路由
|
||||||
│ │ ├── bot.ts # 机器人管理 CRUD
|
│ │ ├── bot.ts # 机器人管理 CRUD
|
||||||
│ │ ├── music.ts # 搜索/歌单/歌词/音质
|
│ │ ├── music.ts # 搜索/歌单/歌词/音质
|
||||||
│ │ ├── player.ts # 播放控制/队列/历史/跳转
|
│ │ ├── player.ts # 播放控制/队列/历史/跳转/FM
|
||||||
|
│ │ ├── favorites.ts # 本地收藏歌单 CRUD
|
||||||
|
│ │ ├── users.ts # 用户管理 + 成员权限
|
||||||
│ │ └── auth.ts # QR登录/Cookie/SMS
|
│ │ └── auth.ts # QR登录/Cookie/SMS
|
||||||
│ └── index.ts # 入口(启动所有服务)
|
│ └── index.ts # 入口(启动所有服务)
|
||||||
├── web/src/ # 前端源码 (Vue 3)
|
├── web/src/ # 前端源码 (Vue 3)
|
||||||
@@ -458,7 +470,7 @@ pip install -U yt-dlp
|
|||||||
|
|
||||||
## 配置文件
|
## 配置文件
|
||||||
|
|
||||||
`config.json` 在首次运行时自动生成,可手动编辑:
|
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -472,10 +484,15 @@ pip install -U yt-dlp
|
|||||||
"adminPassword": "",
|
"adminPassword": "",
|
||||||
"adminGroups": [],
|
"adminGroups": [],
|
||||||
"autoReturnDelay": 300,
|
"autoReturnDelay": 300,
|
||||||
"autoPauseOnEmpty": true
|
"autoPauseOnEmpty": true,
|
||||||
|
"idleTimeoutMinutes": 0,
|
||||||
|
"publicUrl": "",
|
||||||
|
"trustProxy": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
|
||||||
|
|
||||||
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
|
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
|
||||||
|
|
||||||
### 反向代理部署注意事项
|
### 反向代理部署注意事项
|
||||||
@@ -499,6 +516,9 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部
|
|||||||
**Q:提示"无法获取播放链接"?**
|
**Q:提示"无法获取播放链接"?**
|
||||||
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
||||||
|
|
||||||
|
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
|
||||||
|
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
||||||
|
|
||||||
**Q:如何更换机器人所在频道?**
|
**Q:如何更换机器人所在频道?**
|
||||||
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
||||||
|
|
||||||
@@ -531,7 +551,16 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
|||||||
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
||||||
|
|
||||||
**Q:成员(member)能做什么?不能做什么?**
|
**Q:成员(member)能做什么?不能做什么?**
|
||||||
A:成员可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**不能**:管理其他用户、查看操作审计日志、降级或删除管理员。
|
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
|
||||||
|
|
||||||
|
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
|
||||||
|
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
|
||||||
|
|
||||||
|
**Q:什么是"专属链接"?怎么用?**
|
||||||
|
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
|
||||||
|
|
||||||
|
**Q:机器人播放时突然自动暂停了?**
|
||||||
|
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。可在 **设置 → 行为设置** 关闭"频道无人时自动暂停"。
|
||||||
|
|
||||||
**Q:如何把某个用户从成员升级为管理员?**
|
**Q:如何把某个用户从成员升级为管理员?**
|
||||||
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
|
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
|
||||||
@@ -556,6 +585,20 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
|||||||
|
|
||||||
### 最新版本
|
### 最新版本
|
||||||
|
|
||||||
|
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||||
|
|
||||||
|
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||||
|
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||||
|
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||||
|
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认开启)。
|
||||||
|
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
|
||||||
|
|
||||||
|
**Bug 修复**
|
||||||
|
|
||||||
|
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
|
||||||
|
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
|
||||||
|
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
|
||||||
|
|
||||||
**WebUI 鉴权与权限系统**
|
**WebUI 鉴权与权限系统**
|
||||||
|
|
||||||
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
||||||
|
|||||||
+87
-32
@@ -6,12 +6,13 @@ import {
|
|||||||
} from "../ts-protocol/client.js";
|
} from "../ts-protocol/client.js";
|
||||||
import { AudioPlayer } from "../audio/player.js";
|
import { AudioPlayer } from "../audio/player.js";
|
||||||
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
||||||
import type { MusicProvider } from "../music/provider.js";
|
import type { MusicProvider, Song } from "../music/provider.js";
|
||||||
import {
|
import {
|
||||||
parseCommand,
|
parseCommand,
|
||||||
isAdminCommand,
|
isAdminCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
@@ -71,6 +72,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
private fmProvider: MusicProvider | null = null;
|
private fmProvider: MusicProvider | null = null;
|
||||||
|
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||||
|
private lastSearchResults: Song[] = [];
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -334,6 +337,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
throw new Error("Bot is not connected to TeamSpeak");
|
throw new Error("Bot is not connected to TeamSpeak");
|
||||||
}
|
}
|
||||||
switch (cmd.name) {
|
switch (cmd.name) {
|
||||||
|
case "search":
|
||||||
|
case "find":
|
||||||
|
return this.cmdSearch(cmd);
|
||||||
case "play":
|
case "play":
|
||||||
return this.cmdPlay(cmd);
|
return this.cmdPlay(cmd);
|
||||||
case "add":
|
case "add":
|
||||||
@@ -467,36 +473,84 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
/**
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
|
||||||
const provider = this.getProvider(cmd.flags);
|
* forms (issue #90):
|
||||||
const result = await provider.search(cmd.args, 1);
|
* 1) "#N" — the Nth result of the previous !search
|
||||||
if (result.songs.length === 0)
|
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
|
||||||
return `No results found for: ${cmd.args}`;
|
* 3) plain text — search, returning the single most-popular hit (legacy)
|
||||||
|
*/
|
||||||
|
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
|
||||||
|
const args = (cmd.args ?? "").trim();
|
||||||
|
const p = this.config.commandPrefix;
|
||||||
|
|
||||||
const song = result.songs[0];
|
// 1) "#N" — pick from the previous !search.
|
||||||
|
const sel = parseSelectionIndex(args);
|
||||||
|
if (sel !== null) {
|
||||||
|
if (this.lastSearchResults.length === 0)
|
||||||
|
return { error: `No recent search. Use ${p}search <name> first.` };
|
||||||
|
if (sel > this.lastSearchResults.length)
|
||||||
|
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
|
||||||
|
return { song: this.lastSearchResults[sel - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) id:/URL — fetch that exact song.
|
||||||
|
const ref = parseSongRef(args);
|
||||||
|
if (ref) {
|
||||||
|
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
|
||||||
|
const song = await provider.getSongDetail(ref.id);
|
||||||
|
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
|
||||||
|
return { song: { ...song, platform: provider.platform } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Plain search term — single most-popular hit (historical behavior).
|
||||||
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
const result = await provider.search(args, 1);
|
||||||
|
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
|
||||||
|
return { song: { ...result.songs[0], platform: provider.platform } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
|
||||||
|
const p = this.config.commandPrefix;
|
||||||
|
if (!cmd.args) return `Usage: ${p}search <name> [-q|-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}`;
|
||||||
|
this.lastSearchResults = result.songs.map((s) => ({ ...s, platform: provider.platform }));
|
||||||
|
const lines = this.lastSearchResults.map(
|
||||||
|
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
|
||||||
|
...lines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
||||||
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
|
if (error) return error;
|
||||||
|
const song0 = song!;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song0 });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
// Reset failure counter on user-initiated play
|
// Reset failure counter on user-initiated play
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
if (!ok) return `Cannot play: ${song.name}`;
|
if (!ok) return `Cannot play: ${song0.name}`;
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${song0.name} - ${song0.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !add <song name>";
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
||||||
const provider = this.getProvider(cmd.flags);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
const result = await provider.search(cmd.args, 1);
|
if (error) return error;
|
||||||
if (result.songs.length === 0)
|
const s = song!;
|
||||||
return `No results found for: ${cmd.args}`;
|
|
||||||
|
|
||||||
const song = result.songs[0];
|
|
||||||
const wasIdle = this.player.getState() === "idle";
|
const wasIdle = this.player.getState() === "idle";
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...s });
|
||||||
|
|
||||||
// If nothing was playing, start this newly-added song immediately.
|
// If nothing was playing, start this newly-added song immediately.
|
||||||
// Matches /api/player/:id/add-by-id behavior so both add paths feel
|
// Matches /api/player/:id/add-by-id behavior so both add paths feel
|
||||||
@@ -506,21 +560,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
await this.resolveAndPlay(this.queue.current()!);
|
await this.resolveAndPlay(this.queue.current()!);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playnext <song name>";
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
||||||
const provider = this.getProvider(cmd.flags);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
const result = await provider.search(cmd.args, 1);
|
if (error) return error;
|
||||||
if (result.songs.length === 0)
|
const s = song!;
|
||||||
return `No results found for: ${cmd.args}`;
|
|
||||||
|
|
||||||
const song = result.songs[0];
|
|
||||||
const wasIdle = this.player.getState() === "idle";
|
const wasIdle = this.player.getState() === "idle";
|
||||||
// Capture the slot addNext WILL insert at, before mutating the queue.
|
// Capture the slot addNext WILL insert at, before mutating the queue.
|
||||||
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
|
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
|
||||||
@@ -531,19 +583,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.getCurrentIndex() < 0
|
this.queue.getCurrentIndex() < 0
|
||||||
? this.queue.size()
|
? this.queue.size()
|
||||||
: this.queue.getCurrentIndex() + 1;
|
: this.queue.getCurrentIndex() + 1;
|
||||||
this.queue.addNext({ ...song, platform: provider.platform });
|
this.queue.addNext({ ...s });
|
||||||
|
|
||||||
if (wasIdle) {
|
if (wasIdle) {
|
||||||
this.queue.playAt(insertedAt);
|
this.queue.playAt(insertedAt);
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
if (!ok) return `Cannot play: ${song.name}`;
|
if (!ok) return `Cannot play: ${s.name}`;
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Up next: ${song.name} - ${song.artist}`;
|
return `Up next: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
@@ -868,11 +920,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
const p = this.config.commandPrefix;
|
const p = this.config.commandPrefix;
|
||||||
return [
|
return [
|
||||||
"TSMusicBot Commands:",
|
"TSMusicBot Commands:",
|
||||||
`${p}play <song> — Search and play`,
|
`${p}play <song> — Search and play (most popular match)`,
|
||||||
`${p}play -q <song> — Search from QQ Music`,
|
`${p}play -q <song> — Search from QQ Music`,
|
||||||
`${p}play -b <song> — Search from BiliBili`,
|
`${p}play -b <song> — Search from BiliBili`,
|
||||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
||||||
`${p}add <song> — Add to queue`,
|
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
||||||
|
`${p}play #N — Play the Nth result of the last ${p}search`,
|
||||||
|
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
|
||||||
|
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
||||||
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
||||||
`${p}pause/resume — Pause/resume`,
|
`${p}pause/resume — Pause/resume`,
|
||||||
`${p}next/prev — Next/previous`,
|
`${p}next/prev — Next/previous`,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
|
|
||||||
|
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||||
|
it("returns null for a plain search term", () => {
|
||||||
|
expect(parseSongRef("Die For You")).toBeNull();
|
||||||
|
expect(parseSongRef("周杰伦 晴天")).toBeNull();
|
||||||
|
expect(parseSongRef("")).toBeNull();
|
||||||
|
// A bare number is NOT treated as an id (a song may be named "2002").
|
||||||
|
expect(parseSongRef("2002")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
|
||||||
|
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
|
||||||
|
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips trailing punctuation from a pasted id:", () => {
|
||||||
|
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
|
||||||
|
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
|
||||||
|
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
|
||||||
|
// These reuse ?id= but are not songs — they should fall through to search,
|
||||||
|
// not misresolve to getSongDetail(collectionId) and error "no song".
|
||||||
|
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
|
||||||
|
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
|
||||||
|
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
|
||||||
|
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
|
||||||
|
// A genuine song URL is still parsed.
|
||||||
|
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses NetEase song URLs", () => {
|
||||||
|
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
|
||||||
|
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
|
||||||
|
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses QQ song URLs", () => {
|
||||||
|
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
|
||||||
|
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses BiliBili BV ids (bare or in a URL)", () => {
|
||||||
|
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||||
|
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||||
|
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSelectionIndex (#90 pick from last search)", () => {
|
||||||
|
it("parses #N tokens (1-based)", () => {
|
||||||
|
expect(parseSelectionIndex("#1")).toBe(1);
|
||||||
|
expect(parseSelectionIndex("#2")).toBe(2);
|
||||||
|
expect(parseSelectionIndex("# 3")).toBe(3);
|
||||||
|
expect(parseSelectionIndex(" #10 ")).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-selections", () => {
|
||||||
|
expect(parseSelectionIndex("2")).toBeNull();
|
||||||
|
expect(parseSelectionIndex("#0")).toBeNull();
|
||||||
|
expect(parseSelectionIndex("#-1")).toBeNull();
|
||||||
|
expect(parseSelectionIndex("Die For You")).toBeNull();
|
||||||
|
expect(parseSelectionIndex("#2 extra")).toBeNull();
|
||||||
|
expect(parseSelectionIndex("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
||||||
|
* so same-name songs can be disambiguated instead of always getting the single
|
||||||
|
* most-popular search hit (issue #90).
|
||||||
|
*
|
||||||
|
* Two mechanisms:
|
||||||
|
* - A song reference: an explicit id / platform URL → play that exact song.
|
||||||
|
* - A selection index: "#N" → the Nth result of the previous !search.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SongRef {
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Platform inferred from a URL. `null` means the platform wasn't encoded in
|
||||||
|
* the reference (e.g. a bare `id:`), so the caller should fall back to the
|
||||||
|
* command's flags / default provider.
|
||||||
|
*/
|
||||||
|
platform: "netease" | "qq" | "bilibili" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect an explicit song reference in a query. Recognizes:
|
||||||
|
* - `id:<id>` → platform from flags/default
|
||||||
|
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
|
||||||
|
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
|
||||||
|
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
|
||||||
|
* Returns `null` for a plain search term (the common case).
|
||||||
|
*/
|
||||||
|
export function parseSongRef(raw: string): SongRef | null {
|
||||||
|
const q = (raw ?? "").trim();
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
// Explicit "id:<id>" — platform decided by the command's flags/default.
|
||||||
|
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
|
||||||
|
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
|
||||||
|
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
|
||||||
|
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
|
||||||
|
|
||||||
|
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
|
||||||
|
// a "BV..." token never collides with them).
|
||||||
|
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
|
||||||
|
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
|
||||||
|
return { id: bv[0], platform: "bilibili" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
|
||||||
|
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
|
||||||
|
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
|
||||||
|
// confusing "no song" error instead of falling back to a normal search.
|
||||||
|
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
|
||||||
|
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
|
||||||
|
if (m) return { id: m[1], platform: "netease" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ song URL.
|
||||||
|
if (/y\.qq\.com/i.test(q)) {
|
||||||
|
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
|
||||||
|
if (m) return { id: m[1], platform: "qq" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a "#N" selection token (1-based) referencing the previous !search.
|
||||||
|
* Returns the positive integer, or `null` when the query isn't a selection.
|
||||||
|
*/
|
||||||
|
export function parseSelectionIndex(raw: string): number | null {
|
||||||
|
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user