Compare commits

..
6 Commits
Author SHA1 Message Date
TIANYAO ZHANG 75f09694a3 Merge pull request #96 from ZHANGTIANYAO1/fix/song-ref-url-corner-cases
fix(song-ref): NetEase collection URLs + trailing-punct ids (#90 follow-up)
2026-06-16 22:06:41 +08:00
saopig1andClaude Opus 4.8 6d56f1f371 fix(song-ref): don't misparse NetEase collection URLs / trailing-punct ids (#90 follow-up)
Corner-case review of the #90 play-by-id parser found two reachable issues:

- A NetEase playlist/album/artist/toplist/djradio share URL (which reuses ?id=)
  was matched as a SONG id, so pasting one into !play called getSongDetail() on
  a collection id and returned a confusing 'No song found' instead of falling
  back to a normal search. Guard the id= branch to exclude collection pages.
- The id: prefix captured trailing punctuation from a chat paste ('id:12345.' ->
  '12345.'), which then failed to resolve. Strip trailing .,;)] from the id.

Both fall back to safe behavior (plain search / clean id). Tests added for
collection URLs and pasted ids with punctuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:06:17 +08:00
TIANYAO ZHANG 64328d7bdf Merge pull request #95 from ZHANGTIANYAO1/feat/play-by-id-and-search
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
2026-06-16 21:52:36 +08:00
saopig1andClaude Opus 4.8 287dd240b1 feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:

- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song

Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.

Fixes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:49:45 +08:00
TIANYAO ZHANG 22ec7328fa Merge pull request #94 from ZHANGTIANYAO1/docs/update-readme-merged-features
docs(readme): document new features (permissions/favorites/scope/auto-pause/QQ FM) + recent fixes
2026-06-16 21:26:36 +08:00
saopig1andClaude Opus 4.8 540641700d docs(readme): document permissions, favorites, scope, auto-pause, QQ FM + recent fixes
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
  local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
  complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:08 +08:00
4 changed files with 287 additions and 47 deletions

No files matched your search

+58 -15
View File
@@ -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
View File
@@ -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`,
+69
View File
@@ -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();
});
});
+73
View File
@@ -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;
}