diff --git a/.gitignore b/.gitignore
index 23a3dc6..7a588c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ dist/
config.json
cookies/
.superpowers/
+/bin/
+scripts/navbar_bigger.png
diff --git a/README.md b/README.md
index 3dded19..dc17d10 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-
+
TSMusicBot
- TeamSpeak 3 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板
+ TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
@@ -16,14 +16,15 @@
+
+
+
----
-
## 功能特性
-- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源
-- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器
+- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
+- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
@@ -126,6 +127,32 @@ sudo ./scripts/install.sh
## 更新升级
+> **⚠️ 从使用 `@honeybbq/teamspeak-client 0.1.x` 的旧版本升级时的重要变更**
+>
+> 本项目已将底层 TeamSpeak 协议库升级到 `0.2.x` 并移除了内置的 TS6 兼容层,改用库自带的通用 `clientinit` 协议。这涉及一次**数据库迁移**:
+>
+> **旧的身份(identity)不兼容新的加密握手路径。** `0.1.0` 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 `0.1.1` 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。`0.1.0` 生成的身份与 `0.2.x` 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。
+>
+> **解决办法**:升级后清空受影响机器人的 `identity` 字段,下次启动时程序会自动生成新身份并持久化。
+>
+> ```bash
+> # 对每个需要迁移的机器人执行(替换 为实际 UUID):
+> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
+> db.execute(\"UPDATE bot_instances SET identity=NULL WHERE id=''\"); \
+> db.commit()"
+>
+> # 或者清空所有机器人的身份:
+> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
+> db.execute('UPDATE bot_instances SET identity=NULL'); db.commit()"
+> ```
+>
+> **影响范围**:
+> - ✅ TS3 服务器 + 旧身份:在多数情况下仍可正常工作(TS3 对 legacy 编码更宽容),可选择不清空
+> - ❌ TS6 服务器 + 旧身份:**必须**清空身份才能连接
+> - ⚠️ 清空身份后,TS 服务器会把机器人识别为**全新的客户端**。之前手动赋予机器人的**服务器组需要用新 UID 重新授予一次**,之后每次重启都会自动保留
+>
+> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
+
### Windows 用户
```
@@ -186,7 +213,7 @@ npm run build
sudo systemctl start tsmusicbot
```
-> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
+> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
## 使用说明
@@ -217,6 +244,7 @@ sudo systemctl start tsmusicbot
| `!play <歌名>` | 搜索并播放 |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
+| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!add <歌名>` | 添加到播放队列 |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
@@ -269,10 +297,14 @@ tsmusicbot/
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
+│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
-│ ├── ts-protocol/ # TS3 客户端协议
-│ │ └── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
+│ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议)
+│ │ ├── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
+│ │ ├── protocol-detect.ts # 服务器协议自动检测(TS3 vs TS6)
+│ │ ├── http-query.ts # TS6 HTTP Query 客户端(替代 TS3 ServerQuery)
+│ │ └── ts6-compat.ts # TS6 兼容中间件(版本升级 + 签名)
│ ├── web/ # Web 后端
│ │ ├── server.ts # Express + WebSocket 服务
│ │ ├── websocket.ts # 实时状态广播
@@ -310,7 +342,7 @@ tsmusicbot/
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
-| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议,兼容 TS3/TS5/TS6) |
+| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
@@ -319,6 +351,55 @@ tsmusicbot/
| **图标** | @iconify/vue |
| **日志** | pino |
+## 可选:YouTube 音源
+
+YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
+
+### 启用方式(任选其一)
+
+**方式一:项目本地 `bin/` 目录(推荐)**
+
+将 `yt-dlp` 可执行文件放到项目根目录下的 `bin/` 文件夹,程序会优先使用此路径。该目录已被 `.gitignore` 忽略,不会影响代码更新。
+
+```bash
+# Windows(PowerShell 或 Git Bash)
+mkdir bin
+curl -L -o bin/yt-dlp.exe https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
+
+# Linux / macOS
+mkdir -p bin
+curl -L -o bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
+chmod +x bin/yt-dlp
+```
+
+**方式二:系统级安装(让 `yt-dlp` 在 `PATH` 中可用)**
+
+```bash
+# Windows
+winget install yt-dlp
+
+# macOS
+brew install yt-dlp
+
+# Debian/Ubuntu
+sudo apt install yt-dlp
+
+# 通用(Python 环境下)
+pip install -U yt-dlp
+```
+
+### 验证是否可用
+
+重启机器人程序,在 WebUI 或 `!play -y lofi` 测试搜索。若 `bin/` 和 `PATH` 中都找不到 `yt-dlp`,YouTube 搜索会静默返回空结果(不会影响其他音源),其余功能正常。
+
+### 注意事项
+
+- YouTube 音源通过 `yt-dlp` 本地调用实现,不依赖 API Key,也无需登录
+- 播放的是视频的最佳音频流(`bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio`),由 FFmpeg 解码
+- 音质由源视频决定,不受音质设置影响
+- 受 YouTube 风控/地域限制,部分视频可能无法播放
+- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本
+
## 配置文件
`config.json` 在首次运行时自动生成,可手动编辑:
@@ -341,6 +422,9 @@ tsmusicbot/
## 常见问题
+**Q:支持 TeamSpeak 6 Server 吗?**
+A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。
+
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
@@ -369,6 +453,9 @@ A:原生模块(opus、sqlite3)需要编译工具,Dockerfile 已包含。
**Q:B站视频搜索不到结果?**
A:B站搜索需要 buvid3 匿名 Cookie(程序启动时自动获取)。如果失败,重启程序即可。登录B站账号后搜索效果更好。
+**Q:YouTube 平台搜索返回空结果?**
+A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:YouTube 音源](#可选youtube-音源) 章节。快速验证:在项目根目录执行 `bin/yt-dlp --version`(或系统 `yt-dlp --version`),能打印版本号即可。若 yt-dlp 已安装但仍搜索失败,通常是网络/地域问题或 yt-dlp 版本过旧(执行 `yt-dlp -U` 升级)。
+
**Q:如何更新到新版本?**
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
@@ -380,12 +467,73 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
4. 推送分支 (`git push origin feature/新功能`)
5. 提交 Pull Request
+## 更新日志
+
+> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
+
+### 最新版本
+
+**协议层 & 稳定性**
+
+- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
+ - ⚠️ **破坏性**:`0.1.0` 生成的旧身份与新握手路径不兼容,升级时需要迁移。详见 [更新升级](#更新升级) 章节顶部的警告。
+- **修复 `startBot` 与 `stopBot` 之间的竞态**:mid-handshake 被替换的 BotInstance 不再泄漏 TS 会话,`disconnect()` 被 `connect()` 的 await 插队时不再错误地把 `connected` 翻回 `true`。
+- **修复播放条自动刷新 bug**:BotManager 现在在创建新 BotInstance 时 emit `botInstance` 事件,WebSocket 监听器会立即重新挂接到新实例,播放状态变化无需手动刷新页面。
+- **`connect()` 增加 15 秒超时**:握手卡住时会清理掉挂起的实例并返回 500,不再无限阻塞 HTTP 请求和 UI。
+- **识别持久化修复**:`startBot` 现在会从数据库读取 `identity` 传给新 BotInstance,服务器组在机器人重启后能保留。
+
+**HTTP API 加固**
+
+- 新增输入校验,拒绝无效值并返回 **400**(之前会返回 200 包装 usage-text 字符串):
+ - `/volume`:非数字、`NaN`/`Infinity`、超出 `[0,100]`
+ - `/mode`:不在 `{seq, loop, random, rloop}` 中的值
+ - `/seek`:`NaN`/`Infinity`、负数、字符串
+ - `/play-at`:索引越界(**先**校验再停止当前播放,避免误杀正在播的歌)
+- **修复 YouTube 平台路由**:`/play`、`/add`、`/playlist`、`/play-by-id`、`/add-by-id`、`/play-playlist` 现在都正确处理 `platform=youtube`(之前会静默回退到网易云)。
+- **修复 `/auth/status?platform=youtube` 数据泄漏**:之前会回退到网易云并返回网易云用户的昵称 + 头像 URL,现在正确路由到 YouTube provider 并报告 `yt-dlp` 的实际可用状态。
+- **`/auth/cookie` 拒绝 `platform=youtube`**,防止意外覆盖网易云 cookie。
+
+**连接状态一致性**
+
+- 断开连接时,音频命令(`play`/`add`/`next`/`prev`/`playlist`/`album`/`fm`)返回 **400 "Bot is not connected to TeamSpeak"**;配置类命令(`volume`/`mode`/`clear`/`stop`/`queue`/`now`/`lyrics`)仍可正常工作,保持 UI 可用。
+- `resolveAndPlay` 在网络请求(URL 解析)前后都会检查 `this.connected`,防止在解析期间被 `stop()` 中断后仍然启动 ffmpeg。
+- `tsClient` 的 `disconnected` 事件处理器现在总是清理播放器状态,不再因为 `connect()` 从未完成而遗留 `playing=true` 的僵尸状态。
+
+**功能改进**
+
+- **YouTube 音源(可选)**:新增基于 `yt-dlp` 的 YouTube provider,通过 `!play -y <关键词>` 或 WebUI 平台选项使用。未安装 `yt-dlp` 时静默降级、返回空结果,不影响其他音源。详见 [可选:YouTube 音源](#可选youtube-音源)。
+- **Bot Selector UI**:
+ - 始终可见(不再只有 ≥2 个机器人时才显示)
+ - 尺寸放大(更大的按钮、字体、状态图标)
+ - 每行增加 **电源按键**(一键启动/停止对应机器人,带禁用态与播放状态高亮)
+ - 每行增加 **链接按钮**(复制机器人专属 URL)
+ - 新路由 `/bot/:id`,打开后自动切换到对应机器人
+- **服务器密码登录**:`serverPassword` 字段已加入数据库与 Settings UI,支持加入需要密码的 TS 服务器。
+- **`!add` 一键开播**:在连接状态下向空队列 `!add` 歌曲时自动开始播放(之前只会入队,需要再 `!play` 或 `!next`)。
+- **WebSocket 新增 `botRemoved` 事件**:删除机器人后 UI 会立即从列表中移除(之前需要手动刷新页面)。
+
+**内部修复**
+
+- **`PlayQueue.remove()` 当前歌曲移除 bug**:移除正在播放的歌曲时,`next()` 不再跳过紧跟其后的那首歌。
+- **投票跳过**:需要的票数现在至少为 1(避免 `needed=0` 时单人"全票通过"的边界情况);投票计数会在每首新歌开始时自动清零,不再跨歌曲泄漏。
+- 多处输入边界修复:`seek` 防止 `NaN` 毒化 `seekOffset` 导致 `getElapsed()` 永久返回 `NaN`;`play-at` 越界时不再误杀当前播放。
+
+### 历史重要变更
+
+更早的变更请查阅 git log。主要里程碑:
+
+- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
+- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
+- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
+- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
+
## 致谢
感谢以下项目和开发者:
| 项目 | 说明 |
|------|------|
+| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
| [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 |
diff --git a/package-lock.json b/package-lock.json
index e1d8529..adbc059 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.10.0",
- "@honeybbq/teamspeak-client": "^0.1.0",
+ "@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.9",
"axios": "^1.14.0",
@@ -25,7 +25,8 @@
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
- "ws": "^8.20.0"
+ "ws": "^8.20.0",
+ "yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@@ -572,9 +573,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@honeybbq/teamspeak-client": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@honeybbq/teamspeak-client/-/teamspeak-client-0.1.0.tgz",
- "integrity": "sha512-Gh/siE4a0NRxqEqiwbvYn6p4ulphG6IVZaTCuCUjS/+cD6rVvWNrCgbJ0724RdkEY5yndoQ34zkjE/Lglk1wTQ==",
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@honeybbq/teamspeak-client/-/teamspeak-client-0.2.1.tgz",
+ "integrity": "sha512-iLZRWsqeAA/IuEL0zMAhHm9+wQ1YmByXjh9T6PZLYSxFk56T39MpQobT6eohM/PnIz3wQZymd8h701hyliLo2w==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.0.1"
@@ -768,9 +769,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -788,9 +786,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -808,9 +803,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -828,9 +820,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -848,9 +837,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -868,9 +854,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3636,9 +3619,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3660,9 +3640,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3684,9 +3661,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3708,9 +3682,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6298,6 +6269,13 @@
"engines": {
"node": ">= 4.0.0"
}
+ },
+ "node_modules/yt-dlp-wrap": {
+ "version": "2.3.12",
+ "resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz",
+ "integrity": "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 694d5fc..164e7ef 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.10.0",
- "@honeybbq/teamspeak-client": "^0.1.0",
+ "@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.9",
"axios": "^1.14.0",
@@ -30,7 +30,8 @@
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
- "ws": "^8.20.0"
+ "ws": "^8.20.0",
+ "yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
diff --git a/scripts/install.sh b/scripts/install.sh
index 1550c31..4715d15 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -6,9 +6,19 @@ echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
echo ""
+# Resolve script location → project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
+# Verify we're in a valid project directory
+if [ ! -f "$PROJECT_DIR/package.json" ]; then
+ echo "Error: Cannot find package.json in $PROJECT_DIR"
+ echo "Please run this script from the TSMusicBot project directory."
+ exit 1
+fi
+
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
@@ -18,44 +28,57 @@ else
exit 1
fi
-echo "[1/5] Installing system dependencies..."
+echo "[1/6] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
- sudo apt-get install -y -qq curl ffmpeg
+ sudo apt-get install -y -qq curl build-essential python3
;;
centos|rhel|fedora)
- sudo yum install -y curl ffmpeg
+ sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
- sudo pacman -S --noconfirm curl ffmpeg
+ sudo pacman -S --noconfirm curl base-devel python
;;
*)
- echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
+ echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
;;
esac
-echo "[2/5] Installing Node.js 20 LTS..."
+echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
-echo "[3/5] Downloading TSMusicBot..."
-sudo mkdir -p "$INSTALL_DIR"
-if [ -d "$(pwd)/dist" ]; then
- sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
-else
- echo "Please run this script from the TSMusicBot source directory after building."
- exit 1
+echo "[3/6] Installing dependencies..."
+cd "$PROJECT_DIR"
+npm install
+if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
+ (cd "$PROJECT_DIR/web" && npm install)
fi
-echo "[4/5] Installing npm dependencies..."
-cd "$INSTALL_DIR"
-sudo npm install --production
+echo "[4/6] Building project..."
+npm run build
-echo "[5/5] Creating systemd service..."
+echo "[5/6] Copying to $INSTALL_DIR..."
+sudo mkdir -p "$INSTALL_DIR"
+sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
+sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
+sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
+# Copy web frontend if built
+if [ -d "$PROJECT_DIR/web/dist" ]; then
+ sudo mkdir -p "$INSTALL_DIR/web"
+ sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
+fi
+# Copy scripts for future use
+sudo mkdir -p "$INSTALL_DIR/scripts"
+sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
+# Create data directory
+sudo mkdir -p "$INSTALL_DIR/data"
+
+echo "[6/6] Creating systemd service..."
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <= 400, f"play should fail while disconnected"
+
+ r = api(
+ f"/api/player/{bot_id}/add",
+ method="POST",
+ json={"query": "test", "platform": "netease"},
+ )
+ assert r.status_code >= 400, f"add should fail while disconnected"
+
+ r = api(f"/api/player/{bot_id}/next", method="POST")
+ assert r.status_code >= 400, f"next should fail while disconnected"
+
+ print("[PASS] audio commands (play/add/next) rejected when disconnected")
+
+
+def test_disconnect_during_connect_race(bot_id):
+ """A. disconnect() called while connect() is awaiting must win the race.
+
+ Fires a stop 200ms into a start call; after things settle the bot's
+ connected state must be stable (either cleanly disconnected, or cleanly
+ connected if the stop happened after connect completed). It must NOT
+ end up in a weird state where connected=true but a subsequent query
+ shows inconsistent data.
+ """
+ # Ensure disconnected first
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ time.sleep(1) # give TS server a moment to forget us
+
+ def delayed_stop():
+ time.sleep(0.2)
+ try:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ except Exception:
+ pass
+
+ threading.Thread(target=delayed_stop, daemon=True).start()
+ try:
+ r = api(f"/api/bot/{bot_id}/start", method="POST")
+ except Exception as e:
+ r = None
+ print(f"[info] start threw: {e}")
+
+ # Wait for all state transitions to settle
+ time.sleep(2)
+
+ b = get_bot(bot_id)
+ # The key invariant: if connected is false, playing must also be false;
+ # if connected is true, the transport is actually up (we can issue
+ # another command without error).
+ assert not (b["connected"] is False and b["playing"] is True), (
+ f"inconsistent state: connected={b['connected']} playing={b['playing']}"
+ )
+ print(
+ f"[PASS] disconnect-during-connect race — final state consistent "
+ f"(connected={b['connected']} playing={b['playing']})"
+ )
+
+
+def test_resolve_guard(bot_id):
+ """C. resolveAndPlay on a disconnected bot is a no-op.
+
+ We can't directly invoke resolveAndPlay from the API, but we can
+ verify by checking that after a stop, the bot stays idle even if we
+ wait for a trackEnd-like event to fire.
+ """
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ time.sleep(1.5) # more than a frame cycle
+ b = get_bot(bot_id)
+ assert not b["playing"], (
+ f"player should stay stopped after disconnect: {b}"
+ )
+ print("[PASS] player stays idle after disconnect (no ghost autoplay)")
+
+
+def main():
+ bots = api("/api/bot/").json()["bots"]
+ if not bots:
+ print("[skip] no bots")
+ return
+ bot_id = bots[0]["id"]
+ initial = bots[0]["connected"]
+ print(f"[init] bot={bot_id[:8]} initial connected={initial}")
+
+ try:
+ test_config_commands_when_disconnected(bot_id)
+ test_play_rejected_when_disconnected(bot_id)
+ test_resolve_guard(bot_id)
+ test_disconnect_during_connect_race(bot_id)
+ print("ALL GREEN")
+ finally:
+ if initial:
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ wait_connected(bot_id, True)
+ else:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ print(f"[restore] connected={get_bot(bot_id)['connected']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_full_feature.py b/scripts/test_full_feature.py
new file mode 100644
index 0000000..f364dde
--- /dev/null
+++ b/scripts/test_full_feature.py
@@ -0,0 +1,852 @@
+"""Comprehensive feature + corner-case test for TSMusicBot against a
+local TeamSpeak 3 server.
+
+Exercises every major HTTP endpoint, the WebSocket state-broadcast path,
+all music providers, bot lifecycle transitions, and a handful of races
+that have burned us in the past. Designed to be safe to run against a
+real installation: captures the target bot's initial connected/volume/
+mode settings and restores them in `finally`.
+
+Usage:
+ "C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py
+
+Exit code: 0 if every non-skipped test passed, 1 otherwise.
+"""
+from __future__ import annotations
+
+import json
+import threading
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import requests
+
+BASE = "http://localhost:3000"
+POLL_INTERVAL = 0.15
+CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace
+ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy
+
+
+# ----------------------------- HTTP helpers ---------------------------------
+
+
+def api(path: str, method: str = "GET", json_body: Any = None):
+ """Return (status_code, body). Never raises."""
+ try:
+ fn = getattr(requests, method.lower())
+ r = fn(f"{BASE}{path}", json=json_body, timeout=30)
+ try:
+ return r.status_code, r.json()
+ except Exception:
+ return r.status_code, r.text
+ except Exception as e:
+ return None, f"<{type(e).__name__}: {e}>"
+
+
+def get_bot(bot_id: str) -> dict | None:
+ _, data = api("/api/bot/")
+ if not isinstance(data, dict):
+ return None
+ return next((b for b in data.get("bots", []) if b["id"] == bot_id), None)
+
+
+def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool:
+ end = time.time() + timeout
+ while time.time() < end:
+ b = get_bot(bot_id)
+ if b is not None and b["connected"] is want:
+ return True
+ time.sleep(POLL_INTERVAL)
+ return False
+
+
+def start_and_wait(bot_id: str, retries: int = 2) -> bool:
+ """Start the bot, tolerating transient TS3 anti-flood by retrying with
+ exponential backoff. Returns True only when the bot reports connected."""
+ for attempt in range(retries + 1):
+ s, _ = api(f"/api/bot/{bot_id}/start", method="POST")
+ if s == 200 and wait_connected(bot_id, True):
+ return True
+ # If /start returned an error (e.g. connect timeout from our 15s
+ # deadline), back off and retry — TS3 server-side anti-flood
+ # usually clears in a few seconds.
+ if attempt < retries:
+ time.sleep(3.0 * (attempt + 1))
+ # Make sure we're fully stopped before the next attempt so
+ # oldBot.disconnect() doesn't double-fire
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False, timeout=5)
+ return False
+
+
+def stop_and_wait(bot_id: str) -> bool:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ return wait_connected(bot_id, False)
+
+
+def assert_started(bot_id: str):
+ """Helper that raises with a clear message when start fails so test
+ output points at 'could not connect' rather than an empty assertion."""
+ if not start_and_wait(bot_id):
+ raise AssertionError(
+ "could not bring bot online (TS3 server may be anti-flooding "
+ "or unreachable)"
+ )
+
+
+# ----------------------------- test runner ----------------------------------
+
+
+@dataclass
+class TestResult:
+ name: str
+ status: str # PASS / FAIL / ERROR / SKIP
+ detail: str = ""
+
+
+passed = 0
+failed = 0
+skipped = 0
+results: list[TestResult] = []
+
+
+def run(name: str, fn):
+ global passed, failed
+ try:
+ fn()
+ print(f" [PASS] {name}")
+ passed += 1
+ results.append(TestResult(name, "PASS"))
+ except AssertionError as e:
+ print(f" [FAIL] {name}: {e}")
+ failed += 1
+ results.append(TestResult(name, "FAIL", str(e)))
+ except Exception as e:
+ print(f" [ERROR] {name}: {type(e).__name__}: {e}")
+ failed += 1
+ results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}"))
+
+
+def skip(name: str, reason: str):
+ global skipped
+ print(f" [SKIP] {name} ({reason})")
+ skipped += 1
+ results.append(TestResult(name, "SKIP", reason))
+
+
+# ----------------------------- test groups ----------------------------------
+
+
+def group_infrastructure(bot_id: str):
+ print("\n== infrastructure ==")
+
+ def t_health():
+ s, d = api("/api/health")
+ assert s == 200, f"health returned {s}"
+ assert d.get("status") == "ok"
+ run("GET /api/health", t_health)
+
+ def t_list_bots():
+ s, d = api("/api/bot/")
+ assert s == 200
+ assert isinstance(d.get("bots"), list)
+ assert any(b["id"] == bot_id for b in d["bots"])
+ run("GET /api/bot/ lists target bot", t_list_bots)
+
+ def t_bot_config():
+ s, d = api(f"/api/bot/{bot_id}/config")
+ assert s == 200
+ assert d["id"] == bot_id
+ assert "identity" in d
+ assert "serverAddress" in d and "nickname" in d
+ assert "serverPassword" in d, "serverPassword field missing"
+ run("GET /api/bot/:id/config returns all fields", t_bot_config)
+
+ def t_404_on_unknown_bot():
+ s, _ = api("/api/bot/does-not-exist/config")
+ assert s == 404
+ run("404 on unknown bot id", t_404_on_unknown_bot)
+
+ def t_quality_shape():
+ s, d = api("/api/music/quality")
+ assert s == 200
+ for p in ("netease", "qq", "bilibili"):
+ assert p in d, f"{p} missing from quality response"
+ run("GET /api/music/quality shape", t_quality_shape)
+
+
+def group_auth_status():
+ print("\n== auth status per platform ==")
+
+ def t_netease_ok():
+ s, d = api("/api/auth/status?platform=netease")
+ assert s == 200
+ assert d.get("platform") == "netease"
+ assert "loggedIn" in d
+ run("auth status netease", t_netease_ok)
+
+ def t_qq_ok():
+ s, d = api("/api/auth/status?platform=qq")
+ assert s == 200
+ assert d.get("platform") == "qq"
+ run("auth status qq", t_qq_ok)
+
+ def t_bilibili_ok():
+ s, d = api("/api/auth/status?platform=bilibili")
+ assert s == 200
+ assert d.get("platform") == "bilibili"
+ run("auth status bilibili", t_bilibili_ok)
+
+ def t_youtube_routed():
+ # Regression: /auth/status?platform=youtube used to fall through
+ # to NetEase and leak the NetEase user's nickname/avatar.
+ s, d = api("/api/auth/status?platform=youtube")
+ assert s == 200
+ assert d.get("platform") == "youtube", (
+ f"youtube auth status leaked to {d.get('platform')}"
+ )
+ run("auth status youtube routes correctly", t_youtube_routed)
+
+ def t_youtube_cookie_rejected():
+ s, d = api(
+ "/api/auth/cookie",
+ method="POST",
+ json_body={"platform": "youtube", "cookie": "fake"},
+ )
+ assert s == 400, f"youtube cookie should be rejected, got {s}: {d}"
+ run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected)
+
+
+def group_search():
+ print("\n== multi-platform search ==")
+
+ def search(platform: str, query: str = "test"):
+ return api(f"/api/music/search?q={query}&platform={platform}&limit=1")
+
+ def t_netease():
+ s, d = search("netease")
+ assert s == 200
+ assert isinstance(d.get("songs"), list)
+ run("netease search", t_netease)
+
+ def t_qq():
+ s, d = search("qq")
+ assert s == 200
+ # QQ may return 0 results if no cookie, but shouldn't error
+ assert isinstance(d.get("songs"), list)
+ run("qq search (empty ok)", t_qq)
+
+ def t_bilibili():
+ s, d = search("bilibili")
+ assert s == 200
+ assert isinstance(d.get("songs"), list)
+ run("bilibili search", t_bilibili)
+
+ def t_missing_query():
+ s, _ = api("/api/music/search?platform=netease")
+ assert s == 400, "missing q should 400"
+ run("400 on missing query", t_missing_query)
+
+ _, auth = api("/api/auth/status?platform=youtube")
+ youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True
+
+ if youtube_available:
+ def t_youtube():
+ s, d = search("youtube", "lofi")
+ assert s == 200
+ songs = d.get("songs", [])
+ assert len(songs) >= 1, "expected at least 1 YouTube result"
+ assert songs[0]["platform"] == "youtube"
+ run("youtube search (yt-dlp installed)", t_youtube)
+ else:
+ skip("youtube search", "yt-dlp not installed")
+
+
+def group_lifecycle(bot_id: str):
+ print("\n== connection lifecycle ==")
+
+ def t_stop_from_any_state():
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ assert wait_connected(bot_id, False), "bot did not stop"
+ b = get_bot(bot_id)
+ assert not b["playing"], "playing should be false after stop"
+ run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state)
+
+ def t_start_completes_quickly():
+ t0 = time.time()
+ s, d = api(f"/api/bot/{bot_id}/start", method="POST")
+ elapsed = time.time() - t0
+ assert s == 200, f"start failed: {d}"
+ assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)"
+ assert wait_connected(bot_id, True)
+ run("start completes well under 15s deadline", t_start_completes_quickly)
+
+ def t_identity_persists():
+ assert_started(bot_id)
+ _, cfg1 = api(f"/api/bot/{bot_id}/config")
+ id1 = cfg1["identity"]
+ assert id1, "identity empty after first start"
+ assert stop_and_wait(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+ assert_started(bot_id)
+ _, cfg2 = api(f"/api/bot/{bot_id}/config")
+ assert cfg2["identity"] == id1, (
+ f"identity changed across restart: {id1} \u2192 {cfg2['identity']}"
+ )
+ run("identity preserved across stop/start", t_identity_persists)
+
+
+def group_playback(bot_id: str):
+ print("\n== playback ==")
+
+ # Bring the bot online ONCE for the whole playback group, then only
+ # toggle player state (play/pause/stop) between tests. This keeps the
+ # TS3 reconnect count for this group at exactly 1.
+ assert_started(bot_id)
+
+ def t_play_song():
+ s, d = api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ assert s == 200, f"play failed: {d}"
+ time.sleep(1.2)
+ b = get_bot(bot_id)
+ assert b["playing"] is True, f"not playing after /play: {b}"
+ assert b["currentSong"] is not None
+ run("play netease song \u2192 playing=true", t_play_song)
+
+ def t_pause_resume():
+ # Previous test left a song playing
+ api(f"/api/player/{bot_id}/pause", method="POST")
+ time.sleep(0.4)
+ b = get_bot(bot_id)
+ assert b["paused"] is True, f"pause failed: {b}"
+ api(f"/api/player/{bot_id}/resume", method="POST")
+ time.sleep(0.4)
+ b = get_bot(bot_id)
+ assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}"
+ run("pause \u2192 paused, resume \u2192 playing", t_pause_resume)
+
+ def t_volume_change():
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": 42},
+ )
+ assert s == 200
+ time.sleep(0.2)
+ b = get_bot(bot_id)
+ assert b["volume"] == 42, f"volume not applied: {b['volume']}"
+ run("volume change", t_volume_change)
+
+ def t_mode_cycle():
+ for m in ("seq", "loop", "random", "rloop"):
+ s, _ = api(
+ f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m}
+ )
+ assert s == 200
+ b = get_bot(bot_id)
+ assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}"
+ run("all four play modes apply", t_mode_cycle)
+
+ def t_queue_endpoint():
+ s, d = api(f"/api/player/{bot_id}/queue")
+ assert s == 200
+ assert isinstance(d.get("queue"), list)
+ assert "status" in d
+ run("GET /player/:id/queue returns queue+status", t_queue_endpoint)
+
+ def t_elapsed_endpoint():
+ s, d = api(f"/api/player/{bot_id}/elapsed")
+ assert s == 200
+ elapsed = d.get("elapsed")
+ assert isinstance(elapsed, (int, float)) and elapsed >= 0, (
+ f"elapsed should be non-negative number: {elapsed}"
+ )
+ run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint)
+
+ def t_add_autoplay_on_idle():
+ # This specific test needs an IDLE bot — stop first (but keep
+ # connected), then add and confirm auto-play.
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ time.sleep(0.4)
+ b = get_bot(bot_id)
+ assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}"
+ s, d = api(
+ f"/api/player/{bot_id}/add",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ assert s == 200
+ msg = d.get("message", "") if isinstance(d, dict) else ""
+ assert "Now playing" in msg, (
+ f"add on idle bot should auto-play, got: {msg!r}"
+ )
+ time.sleep(0.8)
+ b = get_bot(bot_id)
+ assert b["playing"] is True, f"not playing after add: {b}"
+ run("add on idle bot auto-plays", t_add_autoplay_on_idle)
+
+ # Leave the bot in a clean state for the next group
+ api(f"/api/player/{bot_id}/stop", method="POST")
+
+
+def group_queue_ops(bot_id: str):
+ print("\n== queue operations ==")
+ assert_started(bot_id)
+
+ def t_clear():
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(0.8)
+ api(
+ f"/api/player/{bot_id}/add",
+ method="POST",
+ json_body={"query": "lemon tree", "platform": "netease"},
+ )
+ time.sleep(0.6)
+ b_before = get_bot(bot_id)
+ assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}"
+ api(f"/api/player/{bot_id}/clear", method="POST")
+ time.sleep(0.4)
+ b_after = get_bot(bot_id)
+ assert b_after["queueSize"] == 0
+ run("clear queue empties it", t_clear)
+
+ def t_play_at_invalid_preserves_playback():
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(1.2)
+ assert get_bot(bot_id)["playing"]
+ s, _ = api(
+ f"/api/player/{bot_id}/play-at",
+ method="POST",
+ json_body={"index": 9999},
+ )
+ assert s == 400, f"invalid index should 400, got {s}"
+ time.sleep(0.4)
+ b = get_bot(bot_id)
+ assert b["playing"], "invalid play-at killed the current song"
+ run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback)
+
+ def t_play_at_negative_rejected():
+ s, _ = api(
+ f"/api/player/{bot_id}/play-at",
+ method="POST",
+ json_body={"index": -1},
+ )
+ assert s == 400
+ run("play-at with negative index rejected", t_play_at_negative_rejected)
+
+ api(f"/api/player/{bot_id}/stop", method="POST")
+
+
+def group_input_validation(bot_id: str):
+ print("\n== HTTP input validation ==")
+
+ def t_volume_out_of_range():
+ for bad in (150, -10, 1000, -1):
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": bad},
+ )
+ assert s == 400, f"volume={bad} should 400, got {s}"
+ run("volume out-of-range rejected (400)", t_volume_out_of_range)
+
+ def t_volume_wrong_type():
+ for bad in ("50", None, [50], {"v": 50}):
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": bad},
+ )
+ assert s == 400, f"volume={bad!r} should 400, got {s}"
+ run("volume wrong-type rejected (400)", t_volume_wrong_type)
+
+ def t_volume_missing():
+ s, _ = api(
+ f"/api/player/{bot_id}/volume", method="POST", json_body={}
+ )
+ assert s == 400
+ run("volume missing rejected (400)", t_volume_missing)
+
+ def t_volume_valid():
+ for good in (0, 1, 50, 100):
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": good},
+ )
+ assert s == 200, f"volume={good} should succeed, got {s}"
+ b = get_bot(bot_id)
+ assert b["volume"] == good, f"volume not applied: {b['volume']}"
+ run("valid volumes apply", t_volume_valid)
+
+ def t_mode_invalid():
+ for bad in ("bogus", "", None, 1, "SEQ"):
+ s, _ = api(
+ f"/api/player/{bot_id}/mode",
+ method="POST",
+ json_body={"mode": bad},
+ )
+ assert s == 400, f"mode={bad!r} should 400, got {s}"
+ run("mode invalid rejected (400)", t_mode_invalid)
+
+ def t_mode_missing():
+ s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={})
+ assert s == 400
+ run("mode missing rejected (400)", t_mode_missing)
+
+
+def group_disconnect_corners(bot_id: str):
+ print("\n== disconnected-bot corners ==")
+
+ def t_play_rejected():
+ assert stop_and_wait(bot_id)
+ s, d = api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "x", "platform": "netease"},
+ )
+ assert s >= 400
+ err = (d.get("error") or "") if isinstance(d, dict) else ""
+ assert "not connected" in err.lower(), f"expected 'not connected' error: {d}"
+ run("play rejected while disconnected", t_play_rejected)
+
+ def t_add_rejected():
+ s, _ = api(
+ f"/api/player/{bot_id}/add",
+ method="POST",
+ json_body={"query": "x", "platform": "netease"},
+ )
+ assert s >= 400
+ run("add rejected while disconnected", t_add_rejected)
+
+ def t_next_rejected():
+ s, _ = api(f"/api/player/{bot_id}/next", method="POST")
+ assert s >= 400
+ run("next rejected while disconnected", t_next_rejected)
+
+ def t_volume_allowed():
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": 60},
+ )
+ assert s == 200, "volume should work while disconnected"
+ run("volume allowed while disconnected", t_volume_allowed)
+
+ def t_mode_allowed():
+ s, _ = api(
+ f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"}
+ )
+ assert s == 200, "mode should work while disconnected"
+ run("mode allowed while disconnected", t_mode_allowed)
+
+ def t_clear_allowed():
+ s, _ = api(f"/api/player/{bot_id}/clear", method="POST")
+ assert s == 200, "clear should work while disconnected"
+ run("clear allowed while disconnected", t_clear_allowed)
+
+ def t_player_state_clean():
+ b = get_bot(bot_id)
+ assert not b["playing"] and not b["paused"], f"state leak: {b}"
+ run("no player state leak while disconnected", t_player_state_clean)
+
+
+def group_seek(bot_id: str):
+ print("\n== seek validation ==")
+
+ def t_negative():
+ s, _ = api(
+ f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5}
+ )
+ assert s == 400
+ run("negative seek rejected", t_negative)
+
+ def t_string():
+ s, _ = api(
+ f"/api/player/{bot_id}/seek",
+ method="POST",
+ json_body={"position": "abc"},
+ )
+ assert s == 400
+ run("string seek rejected", t_string)
+
+ def t_nan_literal():
+ r = requests.post(
+ f"{BASE}/api/player/{bot_id}/seek",
+ data='{"position": NaN}',
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}"
+ run("NaN literal seek rejected", t_nan_literal)
+
+ def t_valid_seek():
+ # Seek needs a live connection + playing song. The disconnect-
+ # corners group right before this one left the bot disconnected.
+ assert_started(bot_id)
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(1.5)
+ s, _ = api(
+ f"/api/player/{bot_id}/seek",
+ method="POST",
+ json_body={"position": 25},
+ )
+ assert s == 200
+ time.sleep(0.5)
+ _, d = api(f"/api/player/{bot_id}/elapsed")
+ elapsed = d.get("elapsed")
+ assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, (
+ f"elapsed after seek(25) wrong: {elapsed}"
+ )
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ run("valid seek produces finite elapsed", t_valid_seek)
+
+
+def group_races(bot_id: str):
+ print("\n== race conditions ==")
+
+ def t_disconnect_during_connect():
+ assert stop_and_wait(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+
+ def delayed_stop():
+ time.sleep(0.2)
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+
+ threading.Thread(target=delayed_stop, daemon=True).start()
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ time.sleep(2)
+ b = get_bot(bot_id)
+ assert not (b["connected"] is False and b["playing"] is True), (
+ f"inconsistent state: {b}"
+ )
+ run("disconnect during connect", t_disconnect_during_connect)
+
+ def t_stop_during_url_resolve():
+ assert stop_and_wait(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+ assert_started(bot_id)
+
+ def delayed_stop():
+ time.sleep(0.15)
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+
+ threading.Thread(target=delayed_stop, daemon=True).start()
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(3)
+ b = get_bot(bot_id)
+ assert not (b["connected"] is False and b["playing"] is True), (
+ f"inconsistent state: {b}"
+ )
+ run("stop during URL resolve", t_stop_during_url_resolve)
+
+ def t_rapid_volume_change():
+ # Volume is a config-only command and works while disconnected,
+ # so this test deliberately doesn't call assert_started — we're
+ # validating the API's last-write-wins behavior, not the TS
+ # transport. That also spares the TS3 anti-flood budget.
+ for v in (10, 25, 50, 75, 100, 1):
+ s, _ = api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": v},
+ )
+ assert s == 200, f"volume POST failed: {s}"
+ time.sleep(0.3)
+ b = get_bot(bot_id)
+ assert b["volume"] == 1, f"final volume wrong: {b['volume']}"
+ run("rapid volume changes converge", t_rapid_volume_change)
+
+
+def group_websocket(bot_id: str):
+ print("\n== websocket broadcasts ==")
+ try:
+ from websocket import create_connection
+ except Exception as e:
+ skip("websocket state broadcasts", f"websocket lib unavailable: {e}")
+ return
+
+ try:
+ ws = create_connection("ws://localhost:3000/ws", timeout=5)
+ except Exception as e:
+ skip("websocket state broadcasts", f"connect failed: {e}")
+ return
+
+ ws.settimeout(0.3)
+ messages: list[dict] = []
+ stop_reader = threading.Event()
+
+ def reader():
+ while not stop_reader.is_set():
+ try:
+ raw = ws.recv()
+ if not raw:
+ break
+ try:
+ messages.append(json.loads(raw))
+ except Exception:
+ pass
+ except Exception:
+ # recv() timeout or closed — keep trying until stop_reader
+ if stop_reader.is_set():
+ break
+ continue
+
+ reader_thread = threading.Thread(target=reader, daemon=True)
+ reader_thread.start()
+
+ try:
+ def t_init():
+ time.sleep(0.6)
+ types = [m.get("type") for m in messages]
+ assert "init" in types, f"no init message; got: {types}"
+ run("init message on connect", t_init)
+
+ def t_state_change_on_play():
+ assert_started(bot_id)
+ messages.clear()
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json_body={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(1.5)
+ types = [m.get("type") for m in messages]
+ assert "stateChange" in types, (
+ f"no stateChange after play; got types: {types}"
+ )
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ run("stateChange broadcast on play", t_state_change_on_play)
+
+ def t_bot_disconnected_event():
+ assert_started(bot_id)
+ messages.clear()
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ time.sleep(1.5)
+ types = [m.get("type") for m in messages]
+ assert "botDisconnected" in types or "stateChange" in types, (
+ f"no disconnect event; got: {types}"
+ )
+ run("botDisconnected event on stop", t_bot_disconnected_event)
+ finally:
+ stop_reader.set()
+ try:
+ ws.close()
+ except Exception:
+ pass
+
+
+# ----------------------------- main ----------------------------------------
+
+
+def main() -> int:
+ _, data = api("/api/bot/")
+ if not isinstance(data, dict) or not data.get("bots"):
+ print("[fatal] no bots registered — create one via the WebUI first")
+ return 2
+
+ target = data["bots"][0]
+ bot_id = target["id"]
+ initial_connected = target["connected"]
+ initial_volume = target["volume"]
+ initial_mode = target["playMode"]
+
+ print(f"[init] target bot = {bot_id[:8]} ({target['name']})")
+ print(
+ f"[init] initial state: connected={initial_connected} "
+ f"volume={initial_volume} mode={initial_mode}"
+ )
+
+ try:
+ # Read-only / no-lifecycle groups first — they don't consume TS3
+ # anti-flood budget.
+ group_infrastructure(bot_id)
+ group_auth_status()
+ group_search()
+
+ # Lifecycle-heavy groups — interleave with small breathers so the
+ # TS3 server's per-IP reconnect limit doesn't start throttling us.
+ group_lifecycle(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+
+ group_playback(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+
+ group_queue_ops(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+
+ group_input_validation(bot_id)
+ group_disconnect_corners(bot_id)
+ group_seek(bot_id)
+ time.sleep(ANTIFLOOD_BREATHER)
+
+ group_races(bot_id)
+ # Extra breather before websocket group — races is the heaviest
+ # consumer of TS3 reconnect budget (disconnect-during-connect and
+ # stop-during-url-resolve each burn one cycle), and the websocket
+ # group needs a clean reconnect to observe live state broadcasts.
+ time.sleep(ANTIFLOOD_BREATHER * 3)
+
+ group_websocket(bot_id)
+ finally:
+ # Restore initial state — this runs even if a test raised
+ try:
+ api(
+ f"/api/player/{bot_id}/volume",
+ method="POST",
+ json_body={"volume": initial_volume},
+ )
+ api(
+ f"/api/player/{bot_id}/mode",
+ method="POST",
+ json_body={"mode": initial_mode},
+ )
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ if initial_connected:
+ start_and_wait(bot_id)
+ else:
+ stop_and_wait(bot_id)
+ except Exception as e:
+ print(f"[warn] restore failed: {e}")
+
+ print()
+ print("=" * 60)
+ print(f" PASSED: {passed}")
+ print(f" FAILED: {failed}")
+ print(f" SKIPPED: {skipped}")
+ print("=" * 60)
+
+ if failed > 0:
+ print("\nFailed tests:")
+ for r in results:
+ if r.status in ("FAIL", "ERROR"):
+ print(f" [{r.status}] {r.name}: {r.detail}")
+
+ return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+ import sys
+
+ sys.exit(main())
diff --git a/scripts/test_more_corners.py b/scripts/test_more_corners.py
new file mode 100644
index 0000000..93d43b7
--- /dev/null
+++ b/scripts/test_more_corners.py
@@ -0,0 +1,166 @@
+"""More corner-case regressions.
+
+A. resolveAndPlay disconnect-during-URL-resolve race
+ The bot checks !this.connected at the top of resolveAndPlay, but the
+ URL-resolve await can take several seconds. If stop is called during
+ that window, playback would previously start on a disconnected bot.
+
+B. /seek NaN/Infinity rejection
+ typeof NaN === "number" and NaN < 0 is false, so a plain range check
+ leaks NaN through and corrupts seekOffset / getElapsed.
+"""
+import threading
+import time
+import requests
+
+BASE = "http://localhost:3000"
+
+
+def api(path, method="GET", **kw):
+ return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
+
+
+def get_bot(bot_id):
+ return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
+
+
+def wait_connected(bot_id, want, timeout=15):
+ end = time.time() + timeout
+ while time.time() < end:
+ if get_bot(bot_id)["connected"] is want:
+ return True
+ time.sleep(0.15)
+ return False
+
+
+def test_resolve_play_stop_race(bot_id):
+ """Fire stopBot during the /play call's URL resolve window."""
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ time.sleep(1)
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ wait_connected(bot_id, True)
+
+ # Schedule a stop 150ms into the play call — that lands inside the
+ # provider.getSongUrl await, which is where the race lives.
+ def delayed_stop():
+ time.sleep(0.15)
+ try:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ except Exception:
+ pass
+
+ threading.Thread(target=delayed_stop, daemon=True).start()
+
+ try:
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json={"query": "the mass", "platform": "netease"},
+ )
+ except Exception:
+ pass
+
+ # Give both calls time to settle fully
+ time.sleep(3)
+ b = get_bot(bot_id)
+ # Key invariant: we never want connected=false AND playing=true. That
+ # pair is the exact Bug C symptom and would indicate the resolveAndPlay
+ # post-await check didn't fire.
+ assert not (b["connected"] is False and b["playing"] is True), (
+ f"inconsistent state after race: {b}"
+ )
+ print(
+ f"[PASS] resolveAndPlay stop-race — final state consistent "
+ f"(connected={b['connected']} playing={b['playing']})"
+ )
+
+
+def test_seek_nan_rejected(bot_id):
+ """Verify that NaN and Infinity seek positions are rejected at the API
+ layer (instead of poisoning seekOffset)."""
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ wait_connected(bot_id, True)
+
+ # Start a real song so there is an active playback to seek against
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json={"query": "the mass", "platform": "netease"},
+ )
+ time.sleep(1.2)
+
+ # JSON spec doesn't allow NaN/Infinity literals, but Python's json
+ # encoder emits them as bare tokens when allow_nan=True (the default).
+ # Express's body-parser rejects them as invalid JSON, which itself is
+ # a form of rejection. We additionally verify that sending a string
+ # "NaN" or a negative value is also rejected with a clean 400.
+ r = api(
+ f"/api/player/{bot_id}/seek",
+ method="POST",
+ json={"position": -5},
+ )
+ assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
+
+ r = api(
+ f"/api/player/{bot_id}/seek",
+ method="POST",
+ json={"position": "fifty"},
+ )
+ assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
+
+ # Directly send NaN in raw body (body-parser will likely 400 it)
+ r = requests.post(
+ f"{BASE}/api/player/{bot_id}/seek",
+ data='{"position": NaN}',
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
+
+ # After the junk attempts, a valid seek still works and the elapsed
+ # time is a finite number (not NaN).
+ r = api(
+ f"/api/player/{bot_id}/seek",
+ method="POST",
+ json={"position": 30},
+ )
+ assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
+
+ elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
+ elapsed = elapsed_resp.json().get("elapsed")
+ assert elapsed is not None and isinstance(elapsed, (int, float)), (
+ f"elapsed should be a number, got {elapsed}"
+ )
+ # Could be exactly 30 or a tiny bit more if a frame has advanced
+ assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
+ print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
+
+
+def main():
+ bots = api("/api/bot/").json()["bots"]
+ if not bots:
+ print("[skip] no bots")
+ return
+ bot_id = bots[0]["id"]
+ initial = bots[0]["connected"]
+ print(f"[init] bot={bot_id[:8]} initial connected={initial}")
+
+ try:
+ test_resolve_play_stop_race(bot_id)
+ test_seek_nan_rejected(bot_id)
+ print("ALL GREEN")
+ finally:
+ if initial:
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ wait_connected(bot_id, True)
+ else:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ print(f"[restore] connected={get_bot(bot_id)['connected']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_multibot.py b/scripts/test_multibot.py
new file mode 100644
index 0000000..18f710e
--- /dev/null
+++ b/scripts/test_multibot.py
@@ -0,0 +1,212 @@
+"""Stress-test two bots playing music concurrently on the same TS server.
+
+Creates two temporary bots (or reuses existing named ones), starts them,
+plays music on both, and polls /api/bot/ every 2 seconds to detect when
+(if) either bot disconnects or stops playing. Cleans up on exit.
+
+Usage:
+ python scripts/test_multibot.py --minutes 3
+ python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
+"""
+import argparse
+import sys
+import time
+from dataclasses import dataclass
+
+import requests
+
+API = "http://localhost:3000"
+POLL_INTERVAL = 2.0
+TEST_BOT_NAMES = ("mbtest1", "mbtest2")
+TEST_BOT_NICKS = ("MBTest1", "MBTest2")
+QUERIES = ("the mass", "lofi") # one different song per bot
+
+
+@dataclass
+class BotSnapshot:
+ t: float
+ connected: bool
+ playing: bool
+ song: str | None
+
+
+def api_get(path: str):
+ r = requests.get(f"{API}{path}", timeout=5)
+ r.raise_for_status()
+ return r.json()
+
+
+def api_post(path: str, json=None):
+ r = requests.post(f"{API}{path}", json=json, timeout=15)
+ r.raise_for_status()
+ return r.json()
+
+
+def api_delete(path: str):
+ r = requests.delete(f"{API}{path}", timeout=10)
+ r.raise_for_status()
+ return r.json()
+
+
+def cleanup_existing(names: tuple[str, ...]) -> None:
+ bots = api_get("/api/bot/")["bots"]
+ for b in bots:
+ if b["name"] in names:
+ try:
+ api_post(f"/api/player/{b['id']}/stop")
+ except Exception:
+ pass
+ try:
+ api_delete(f"/api/bot/{b['id']}")
+ print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
+ except Exception as e:
+ print(f"[cleanup] failed to remove {b['name']}: {e}")
+
+
+def create_bot(name: str, nickname: str, host: str, port: int) -> str:
+ res = api_post(
+ "/api/bot/",
+ json={
+ "name": name,
+ "serverAddress": host,
+ "serverPort": port,
+ "nickname": nickname,
+ "autoStart": False,
+ },
+ )
+ bot_id = res["id"]
+ print(f"[create] {name} -> {bot_id}")
+ return bot_id
+
+
+def start_bot(bot_id: str) -> None:
+ api_post(f"/api/bot/{bot_id}/start")
+
+
+def play(bot_id: str, query: str) -> None:
+ api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
+
+
+def snapshot(bot_id: str, t0: float) -> BotSnapshot:
+ bots = api_get("/api/bot/")["bots"]
+ b = next((x for x in bots if x["id"] == bot_id), None)
+ if not b:
+ return BotSnapshot(time.time() - t0, False, False, None)
+ song = b["currentSong"]["name"] if b.get("currentSong") else None
+ return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
+
+
+def run(minutes: float, host: str, port: int) -> int:
+ print(f"[setup] duration={minutes}min host={host}:{port}")
+
+ cleanup_existing(TEST_BOT_NAMES)
+
+ bot_ids = [
+ create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
+ create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
+ ]
+
+ # Start both, allowing a small stagger to avoid handshake collision
+ for i, bid in enumerate(bot_ids):
+ start_bot(bid)
+ print(f"[start] bot{i+1} started")
+ time.sleep(1.5)
+
+ # Wait until both are connected (or bail after 15s)
+ deadline = time.time() + 15
+ while time.time() < deadline:
+ bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
+ if all(bots[b]["connected"] for b in bot_ids):
+ print("[start] both bots connected")
+ break
+ time.sleep(0.5)
+ else:
+ print("[fatal] bots did not both come online in 15s")
+ cleanup_existing(TEST_BOT_NAMES)
+ return 2
+
+ # Kick off playback on both
+ for i, bid in enumerate(bot_ids):
+ play(bid, QUERIES[i])
+ print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
+
+ t0 = time.time()
+ end = t0 + minutes * 60
+ first_drop: dict[str, float] = {}
+ last_state: dict[str, BotSnapshot] = {}
+
+ print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
+ print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
+
+ def fmt(snap: BotSnapshot) -> str:
+ flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
+ song = (snap.song or "").replace("\n", " ")[:30]
+ return f"{flag} {song}"
+
+ try:
+ while time.time() < end:
+ snaps = [snapshot(bid, t0) for bid in bot_ids]
+ elapsed = int(time.time() - t0)
+ row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
+ # Only print when state changes or every 10s
+ changed = False
+ for bid, s in zip(bot_ids, snaps):
+ prev = last_state.get(bid)
+ if (prev is None
+ or prev.connected != s.connected
+ or prev.playing != s.playing
+ or prev.song != s.song):
+ changed = True
+ last_state[bid] = s
+ if not s.connected and bid not in first_drop:
+ first_drop[bid] = s.t
+ if changed or elapsed % 10 == 0:
+ print(row)
+
+ # If both stopped playing but are still connected, re-queue the same song
+ for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
+ if s.connected and not s.playing:
+ try:
+ play(bid, QUERIES[i])
+ except Exception as e:
+ print(f"[warn] re-play bot{i+1} failed: {e}")
+
+ time.sleep(POLL_INTERVAL)
+ except KeyboardInterrupt:
+ print("\n[abort] interrupted")
+
+ # Summary
+ total = time.time() - t0
+ print()
+ print("=" * 60)
+ print(f"Total observed time: {total:.1f}s")
+ for i, bid in enumerate(bot_ids):
+ drop = first_drop.get(bid)
+ if drop is None:
+ print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
+ else:
+ print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
+ print("=" * 60)
+
+ # Cleanup
+ cleanup_existing(TEST_BOT_NAMES)
+ print("[cleanup] done")
+
+ return 0 if not first_drop else 1
+
+
+def main() -> int:
+ p = argparse.ArgumentParser()
+ p.add_argument("--minutes", type=float, default=3.0)
+ p.add_argument("--host", default="127.0.0.1")
+ p.add_argument("--port", type=int, default=9987)
+ args = p.parse_args()
+ try:
+ return run(args.minutes, args.host, args.port)
+ except requests.HTTPError as e:
+ print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
+ return 3
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/test_playbar.py b/scripts/test_playbar.py
new file mode 100644
index 0000000..82dccb4
--- /dev/null
+++ b/scripts/test_playbar.py
@@ -0,0 +1,119 @@
+"""Reproduce / regression-check the player-bar-not-appearing bug.
+
+Captures the bot's initial playback state and restores it on exit so the
+test never leaves the user with surprise music or a cleared queue.
+"""
+import time
+import requests
+from playwright.sync_api import sync_playwright
+
+BASE = "http://localhost:3000"
+
+
+def api(path, method="GET", **kw):
+ fn = getattr(requests, method.lower())
+ r = fn(f"{BASE}{path}", timeout=10, **kw)
+ r.raise_for_status()
+ return r.json() if r.text else None
+
+
+def get_bot(bot_id):
+ return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id)
+
+
+def capture_state(bot_id):
+ b = get_bot(bot_id)
+ return {
+ "playing": b["playing"],
+ "paused": b["paused"],
+ "song": (b["currentSong"] or {}).get("name"),
+ }
+
+
+def main():
+ bots = api("/api/bot/")["bots"]
+ if not bots:
+ print("[skip] no bots")
+ return
+ bot_id = bots[0]["id"]
+ initial = capture_state(bot_id)
+ print(f"[init] initial state: {initial}")
+
+ try:
+ # Clear slate
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ time.sleep(0.6)
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ try:
+ ctx = browser.new_context()
+ ctx.add_init_script(
+ """
+ (() => {
+ const OrigWS = window.WebSocket;
+ window.__wsMessages = [];
+ window.WebSocket = function(...args) {
+ const ws = new OrigWS(...args);
+ ws.addEventListener('message', (ev) => {
+ try {
+ const d = JSON.parse(ev.data);
+ window.__wsMessages.push({type: d.type, botId: d.botId});
+ } catch(e) {}
+ });
+ return ws;
+ };
+ Object.assign(window.WebSocket, OrigWS);
+ })();
+ """
+ )
+ page = ctx.new_page()
+ page.goto(BASE)
+ page.wait_for_load_state("networkidle")
+ time.sleep(0.8)
+
+ assert page.locator(".player-wrapper").count() == 0, (
+ "player bar should be hidden before playback"
+ )
+
+ # Trigger play via API (simulates any play trigger)
+ api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json={"query": "the mass", "platform": "netease"},
+ )
+
+ # Poll for up to 6s to see if player bar appears automatically
+ appeared_at = None
+ for i in range(60):
+ if page.locator(".player-wrapper").count() > 0:
+ appeared_at = i * 0.1
+ break
+ page.wait_for_timeout(100)
+
+ if appeared_at is None:
+ msgs = page.evaluate("() => window.__wsMessages")
+ print(f"[FAIL] player bar never appeared; WS msgs: {msgs}")
+ raise AssertionError("player bar did not auto-show on stateChange")
+ print(f"[PASS] player bar appeared after {appeared_at:.1f}s")
+ finally:
+ browser.close()
+ finally:
+ # Restore: stop the "test" song we triggered, then re-apply initial
+ # state as best we can. We can't re-queue the user's previous song,
+ # but we can at least stop ours and leave the bot idle if it was idle.
+ try:
+ api(f"/api/player/{bot_id}/stop", method="POST")
+ except Exception as e:
+ print(f"[warn] failed to stop test song on cleanup: {e}")
+ post = capture_state(bot_id)
+ print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})")
+ if initial["playing"] and initial["song"]:
+ print(
+ f"[note] initial bot was playing {initial['song']!r}; "
+ "this test cannot resume arbitrary tracks — you may need to restart playback"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_power_button.py b/scripts/test_power_button.py
new file mode 100644
index 0000000..8b81168
--- /dev/null
+++ b/scripts/test_power_button.py
@@ -0,0 +1,100 @@
+"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state.
+
+Captures the target bot's initial connected state and restores it on exit
+(including on assertion failure), so running this test never pollutes the
+user's current bot setup.
+"""
+import time
+import requests
+from playwright.sync_api import sync_playwright
+
+BASE = "http://localhost:3000"
+
+
+def get_bot(bot_id):
+ return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id)
+
+
+def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool:
+ deadline = time.time() + timeout_s
+ while time.time() < deadline:
+ if get_bot(bot_id)["connected"] is want:
+ return True
+ time.sleep(0.2)
+ return False
+
+
+def set_connected(bot_id, want: bool) -> None:
+ """Force the bot into the given connected state via API."""
+ current = get_bot(bot_id)["connected"]
+ if current == want:
+ return
+ endpoint = "start" if want else "stop"
+ requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}")
+ wait_for_connected(bot_id, want)
+
+
+def main():
+ bots = requests.get(f"{BASE}/api/bot/").json()["bots"]
+ if not bots:
+ print("[skip] no bots registered, nothing to test")
+ return
+ target = bots[0]
+ bot_id = target["id"]
+ initial_connected = target["connected"]
+ print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}")
+
+ try:
+ # Force bot disconnected before the test
+ set_connected(bot_id, False)
+ assert not get_bot(bot_id)["connected"], "bot should be disconnected at start"
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ try:
+ page = browser.new_page(viewport={"width": 1440, "height": 900})
+ page.goto(BASE)
+ page.wait_for_load_state("networkidle")
+ time.sleep(0.6)
+
+ # Open dropdown
+ page.locator(".bot-selector-btn").click()
+ page.wait_for_selector(".bot-power-btn")
+
+ # Click the power button to start
+ page.locator(".bot-power-btn").first.click()
+ print("[ui] clicked power (start)")
+
+ assert wait_for_connected(bot_id, True), "bot should be connected after clicking start"
+ print("[api] bot connected = True")
+
+ # Let UI catch up via WS then re-open the dropdown to re-check class
+ time.sleep(1.0)
+ page.locator(".bot-selector-btn").click() # close
+ time.sleep(0.2)
+ page.locator(".bot-selector-btn").click() # reopen
+ page.wait_for_selector(".bot-power-btn.online", timeout=3000)
+ print("[ui] power button now shows .online class")
+
+ # Click again to stop
+ page.locator(".bot-power-btn.online").first.click()
+ print("[ui] clicked power (stop)")
+
+ assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop"
+ print("[api] bot connected = False")
+
+ print("[PASS] power button toggles bot connection")
+ finally:
+ browser.close()
+ finally:
+ # Always restore the initial state so the test never leaves the bot
+ # in an unexpected place
+ set_connected(bot_id, initial_connected)
+ final = get_bot(bot_id)["connected"]
+ print(f"[restore] bot connected={final} (initial was {initial_connected})")
+ if final != initial_connected:
+ print("[warn] failed to restore initial connected state")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_rapid_cycle.py b/scripts/test_rapid_cycle.py
new file mode 100644
index 0000000..a8e7ef4
--- /dev/null
+++ b/scripts/test_rapid_cycle.py
@@ -0,0 +1,130 @@
+"""Regression for the 'connected=False but playing=True' stuck-state bug.
+
+After rapid disconnect/reconnect, the library could drop the connection
+(TS3 server anti-flood or a hung handshake). The bot then ended up in an
+inconsistent state: player.state='playing' but tsClient disconnected.
+
+This test verifies three fixes:
+ Bug A — startBot() has a 15s timeout instead of hanging forever on a
+ stalled handshake. /start returns a clean 500 instead of blocking.
+ Bug B — play/add/etc commands are rejected when the bot is not connected.
+ Bug C — the tsClient 'disconnected' handler always clears player state,
+ even when connect() never completed (so !this.connected).
+
+We also sanity-check that the bot recovers (can start a fresh cycle) after
+a transient failure.
+"""
+import time
+import requests
+
+BASE = "http://localhost:3000"
+
+
+def api(path, method="GET", **kw):
+ return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
+
+
+def get_bot(bot_id):
+ return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
+
+
+def wait_connected(bot_id, want, timeout=15):
+ end = time.time() + timeout
+ while time.time() < end:
+ if get_bot(bot_id)["connected"] is want:
+ return True
+ time.sleep(0.15)
+ return False
+
+
+def main():
+ bots = api("/api/bot/").json()["bots"]
+ if not bots:
+ print("[skip] no bots")
+ return
+ bot_id = bots[0]["id"]
+ initial_connected = bots[0]["connected"]
+ print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}")
+
+ try:
+ # Start from a clean slate
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+
+ # --- Bug B: play while disconnected must be rejected ---
+ r = api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json={"query": "rejection test", "platform": "netease"},
+ )
+ assert r.status_code >= 400, (
+ f"play while disconnected should fail, got {r.status_code} {r.text[:120]}"
+ )
+ assert "not connected" in r.text.lower(), (
+ f"expected 'not connected' error, got: {r.text[:200]}"
+ )
+ b = get_bot(bot_id)
+ assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}"
+ print("[PASS] Bug B — /play rejected while bot disconnected; state untouched")
+
+ # --- Bug C: normal start→play→stop leaves player state clean ---
+ r = api(f"/api/bot/{bot_id}/start", method="POST")
+ assert r.status_code == 200, f"start failed {r.text[:120]}"
+ assert wait_connected(bot_id, True), "bot did not connect within 15s"
+
+ r = api(
+ f"/api/player/{bot_id}/play",
+ method="POST",
+ json={"query": "the mass", "platform": "netease"},
+ )
+ assert r.status_code == 200, f"play failed {r.text[:120]}"
+ time.sleep(1.2)
+ b = get_bot(bot_id)
+ assert b["connected"] and b["playing"], f"should be connected+playing: {b}"
+
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect"
+ b = get_bot(bot_id)
+ assert not b["playing"], (
+ f"player should have stopped after bot disconnect (Bug C): {b}"
+ )
+ print("[PASS] Bug C — stop clears both connected and playing state")
+
+ # --- Bug A: startBot has a deadline and returns a clean error if connect hangs ---
+ # We can't easily force a hang in-process, but we can sanity-check that
+ # startBot returns promptly (well under the 15s cap) on a normal run.
+ t0 = time.time()
+ r = api(f"/api/bot/{bot_id}/start", method="POST")
+ elapsed = time.time() - t0
+ assert r.status_code == 200, f"start failed {r.text[:120]}"
+ assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s"
+ assert wait_connected(bot_id, True), "bot did not connect"
+ print(
+ f"[PASS] Bug A — startBot completed in {elapsed:.2f}s "
+ "(deadline is 15s, would throw on hang)"
+ )
+
+ # --- Recovery: after any failure, another start should work ---
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ # Give TS3 server a moment to forget us (anti-flood grace)
+ time.sleep(2)
+ r = api(f"/api/bot/{bot_id}/start", method="POST")
+ assert r.status_code == 200, f"recovery start failed {r.text[:120]}"
+ assert wait_connected(bot_id, True), "bot did not recover"
+ print("[PASS] recovery — bot reconnects cleanly after a cycle")
+
+ print("ALL GREEN")
+
+ finally:
+ if initial_connected:
+ api(f"/api/bot/{bot_id}/start", method="POST")
+ wait_connected(bot_id, True)
+ else:
+ api(f"/api/bot/{bot_id}/stop", method="POST")
+ wait_connected(bot_id, False)
+ print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/audio/player.ts b/src/audio/player.ts
index f0bcb53..6a0f970 100644
--- a/src/audio/player.ts
+++ b/src/audio/player.ts
@@ -302,6 +302,13 @@ export class AudioPlayer extends EventEmitter {
seek(seconds: number): void {
if (!this.currentUrl) return;
+ // Reject NaN/Infinity/negative — the HTTP layer validates too, but a
+ // bad value here would poison seekOffset and leave getElapsed()
+ // returning NaN until the track ends.
+ if (!Number.isFinite(seconds) || seconds < 0) {
+ this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
+ return;
+ }
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts
index c2f6ee7..e801014 100644
--- a/src/audio/queue.test.ts
+++ b/src/audio/queue.test.ts
@@ -89,6 +89,49 @@ describe("PlayQueue", () => {
expect(queue.list()[1].id).toBe("3");
});
+ it("removing a song before current shifts current index", () => {
+ queue.setMode(PlayMode.Sequential);
+ queue.add(makeSong("A"));
+ queue.add(makeSong("B"));
+ queue.add(makeSong("C"));
+ queue.playAt(2); // playing C at index 2
+ queue.remove(0); // remove A (before current)
+ expect(queue.current()?.id).toBe("C"); // still on C
+ expect(queue.getCurrentIndex()).toBe(1);
+ });
+
+ it("removing the currently-playing song lets next() advance to the shifted song", () => {
+ queue.setMode(PlayMode.Sequential);
+ queue.add(makeSong("A"));
+ queue.add(makeSong("B"));
+ queue.add(makeSong("C"));
+ queue.add(makeSong("D"));
+ queue.playAt(2); // playing C
+ queue.remove(2); // remove C — D shifts into slot 2
+ // Before the fix this returned null (D was silently skipped)
+ expect(queue.next()?.id).toBe("D");
+ });
+
+ it("removing the only song clears the queue", () => {
+ queue.add(makeSong("only"));
+ queue.playAt(0);
+ queue.remove(0);
+ expect(queue.size()).toBe(0);
+ expect(queue.current()).toBeNull();
+ expect(queue.next()).toBeNull();
+ });
+
+ it("removing the last song while playing it advances to null in sequential mode", () => {
+ queue.setMode(PlayMode.Sequential);
+ queue.add(makeSong("A"));
+ queue.add(makeSong("B"));
+ queue.playAt(1); // playing B (last)
+ queue.remove(1);
+ expect(queue.size()).toBe(1);
+ // currentIndex moved to 0, so next() should try to advance past the end
+ expect(queue.next()).toBeNull();
+ });
+
it("clears all songs", () => {
queue.add(makeSong("1"));
queue.add(makeSong("2"));
diff --git a/src/audio/queue.ts b/src/audio/queue.ts
index 4e58ca0..9c26ab0 100644
--- a/src/audio/queue.ts
+++ b/src/audio/queue.ts
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
- platform: "netease" | "qq" | "bilibili";
+ platform: "netease" | "qq" | "bilibili" | "youtube";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
@@ -36,9 +36,14 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
- if (this.currentIndex >= this.songs.length) {
- this.currentIndex = this.songs.length - 1;
- }
+ // Move the pointer back by one so next() in sequential mode advances
+ // to the song that shifted into the removed slot. Without this, the
+ // shifted song is silently skipped because current() incorrectly
+ // returns it (even though the player is still on the removed song)
+ // and next() then increments past it. currentIndex may become -1,
+ // which is fine — it represents "no current song" and next() will
+ // pick index 0.
+ this.currentIndex--;
}
return removed;
diff --git a/src/bot/instance.ts b/src/bot/instance.ts
index a44bfb2..58192bb 100644
--- a/src/bot/instance.ts
+++ b/src/bot/instance.ts
@@ -23,6 +23,7 @@ export interface BotInstanceOptions {
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
+ youtubeProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -51,10 +52,12 @@ export class BotInstance extends EventEmitter {
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
+ private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private connected = false;
+ private disconnectEmitted = false;
private voteSkipUsers = new Set();
private isAdvancing = false;
@@ -65,6 +68,7 @@ export class BotInstance extends EventEmitter {
this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
+ this.youtubeProvider = options.youtubeProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -105,23 +109,42 @@ export class BotInstance extends EventEmitter {
});
this.tsClient.on("disconnected", () => {
+ // Always reset local state — covers the case where connect() never
+ // completed (hanging handshake → 60s library idle timeout) and
+ // this.connected was never flipped to true. Previously this handler
+ // short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
+ // Only emit externally once per lifecycle so clients don't see a
+ // duplicate "disconnected" after an explicit disconnect() call.
+ if (this.disconnectEmitted) return;
+ this.disconnectEmitted = true;
this.emit("disconnected");
});
}
async connect(): Promise {
+ this.disconnectEmitted = false;
await this.tsClient.connect();
+ // Race guard: if disconnect() was called while the handshake was
+ // awaiting, don't flip connected back to true — that would leave the
+ // bot in an inconsistent state (externally "connected" but the tsClient
+ // has already been torn down).
+ if (this.disconnectEmitted) {
+ throw new Error("Connect aborted by concurrent disconnect");
+ }
this.connected = true;
this.emit("connected");
}
disconnect(): void {
this.player.stop();
- this.tsClient.disconnect();
this.connected = false;
- this.emit("disconnected");
+ if (!this.disconnectEmitted) {
+ this.disconnectEmitted = true;
+ this.emit("disconnected");
+ }
+ this.tsClient.disconnect();
}
private async handleTextMessage(msg: TS3TextMessage): Promise {
@@ -162,6 +185,24 @@ export class BotInstance extends EventEmitter {
cmd: ParsedCommand,
msg?: TS3TextMessage
): Promise {
+ // Reject commands that would push audio when the bot isn't connected:
+ // otherwise ffmpeg spawns and voice goes to a half-initialized or
+ // torn-down TS client, leaving player.state="playing" on a disconnected
+ // bot. Config-only commands (vol, mode, clear, stop, queue, now) are
+ // still allowed so the UI stays usable while the bot is offline.
+ const AUDIO_COMMANDS = new Set([
+ "play",
+ "add",
+ "next",
+ "skip",
+ "prev",
+ "playlist",
+ "album",
+ "fm",
+ ]);
+ if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
+ throw new Error("Bot is not connected to TeamSpeak");
+ }
switch (cmd.name) {
case "play":
return this.cmdPlay(cmd);
@@ -212,19 +253,29 @@ export class BotInstance extends EventEmitter {
}
}
- getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
+ getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
+ if (platform === "youtube") return this.youtubeProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private getProvider(flags: Set): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
+ if (flags.has("y")) return this.youtubeProvider;
return this.neteaseProvider;
}
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise {
+ if (!this.connected) {
+ this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
+ 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.).
+ this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
const url = await provider.getSongUrl(song.id);
@@ -232,6 +283,18 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
return false;
}
+ // Re-check connection state AFTER the network round-trip — the URL
+ // resolve can take multiple seconds and the user may have called stop
+ // during that window. Without this, we'd spawn ffmpeg on a
+ // disconnected bot and land back in the same "connected=false but
+ // playing=true" inconsistency that Bug C was about.
+ if (!this.connected) {
+ this.logger.warn(
+ { songId: song.id, name: song.name },
+ "bot disconnected during URL resolve — aborting playback",
+ );
+ return false;
+ }
song.url = url;
this.player.play(url);
this.database.addPlayHistory({
@@ -278,7 +341,20 @@ export class BotInstance extends EventEmitter {
return `No results found for: ${cmd.args}`;
const song = result.songs[0];
+ const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...song, platform: provider.platform });
+
+ // If nothing was playing, start this newly-added song immediately.
+ // Matches /api/player/:id/add-by-id behavior so both add paths feel
+ // the same to the user (add to idle bot → plays now).
+ if (wasIdle) {
+ this.queue.playAt(this.queue.size() - 1);
+ this.player.resetFailures();
+ await this.resolveAndPlay(this.queue.current()!);
+ this.emit("stateChange");
+ return `Now playing: ${song.name} - ${song.artist}`;
+ }
+
this.emit("stateChange");
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
}
@@ -430,8 +506,11 @@ export class BotInstance extends EventEmitter {
if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid);
const clients = await this.tsClient.getClientsInChannel();
- const totalUsers = clients.length - 1;
- const needed = Math.ceil(totalUsers / 2);
+ const totalUsers = clients.length - 1; // exclude the bot itself
+ // At least 1 vote is always required — otherwise a single voter in an
+ // otherwise empty channel (or a transient clients.length=1 race) could
+ // unanimously "win" with needed=0.
+ const needed = Math.max(1, Math.ceil(totalUsers / 2));
const votes = this.voteSkipUsers.size;
if (votes >= needed) {
@@ -472,6 +551,7 @@ export class BotInstance extends EventEmitter {
`${p}play — Search and play`,
`${p}play -q — Search from QQ Music`,
`${p}play -b — Search from BiliBili`,
+ `${p}play -y — Search from YouTube (yt-dlp)`,
`${p}add — Add to queue`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
@@ -490,20 +570,25 @@ export class BotInstance extends EventEmitter {
}
private async playNext(): Promise {
- if (this.isAdvancing) return;
+ if (this.isAdvancing || !this.connected) return;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
- const ok = await this.resolveAndPlay(next);
- if (!ok) {
+ let started = await this.resolveAndPlay(next);
+ if (!started) {
// Skip to next if URL resolve fails (up to 3 retries)
- for (let i = 0; i < 3; i++) {
+ for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
- if (await this.resolveAndPlay(retry)) break;
+ if (await this.resolveAndPlay(retry)) {
+ started = true;
+ break;
+ }
}
+ }
+ if (!started) {
this.player.stop();
}
} else {
diff --git a/src/bot/manager.ts b/src/bot/manager.ts
index 9043f93..0374b82 100644
--- a/src/bot/manager.ts
+++ b/src/bot/manager.ts
@@ -1,13 +1,53 @@
import crypto from "node:crypto";
+import { EventEmitter } from "node:events";
import {
BotInstance,
type BotInstanceOptions,
} from "./instance.js";
import type { MusicProvider } from "../music/provider.js";
+import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
+import type { ServerProtocol } from "../ts-protocol/client.js";
+
+/**
+ * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
+ * server silently drops the connection after initivexpand2), we tear the
+ * instance down instead of waiting for the library's 60s idle timeout, so
+ * the HTTP /start call returns promptly and the UI doesn't lock up.
+ */
+async function connectWithTimeout(
+ bot: BotInstance,
+ ms: number,
+ logger: Logger
+): Promise {
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error(`connect timeout after ${ms}ms`)),
+ ms
+ );
+ });
+ try {
+ await Promise.race([bot.connect(), timeout]);
+ } catch (err) {
+ logger.warn(
+ { err, botId: bot.id },
+ "Connect failed or timed out — tearing down instance"
+ );
+ try {
+ bot.disconnect();
+ } catch {
+ // ignore teardown errors
+ }
+ throw err;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
export interface CreateBotParams {
name: string;
serverAddress: string;
@@ -17,13 +57,20 @@ export interface CreateBotParams {
defaultChannel?: string;
channelPassword?: string;
autoStart?: boolean;
+ /** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */
+ serverProtocol?: ServerProtocol;
+ /** API key for TS6 HTTP Query (port 10080/10443). */
+ ts6ApiKey?: string;
+ /** Password required to join the TS server. */
+ serverPassword?: string;
}
-export class BotManager {
+export class BotManager extends EventEmitter {
private bots = new Map();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
+ private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -36,9 +83,11 @@ export class BotManager {
config: BotConfig,
logger: Logger
) {
+ super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
+ this.youtubeProvider = new YouTubeProvider();
this.database = database;
this.config = config;
this.logger = logger;
@@ -57,16 +106,21 @@ export class BotManager {
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword,
+ serverPassword: params.serverPassword,
+ serverProtocol: params.serverProtocol,
+ ts6ApiKey: params.ts6ApiKey,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
+ youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(id, bot);
+ this.emit("botInstance", bot);
this.database.saveBotInstance({
id,
@@ -77,6 +131,9 @@ export class BotManager {
defaultChannel: params.defaultChannel ?? "",
channelPassword: params.channelPassword ?? "",
autoStart: params.autoStart ?? false,
+ serverProtocol: params.serverProtocol ?? "",
+ ts6ApiKey: params.ts6ApiKey ?? "",
+ serverPassword: params.serverPassword ?? "",
});
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
@@ -90,6 +147,7 @@ export class BotManager {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
+ this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -106,6 +164,9 @@ export class BotManager {
nickname: params.nickname ?? existing.nickname,
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
channelPassword: params.channelPassword ?? existing.channelPassword,
+ serverProtocol: params.serverProtocol ?? existing.serverProtocol,
+ ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
+ serverPassword: params.serverPassword ?? existing.serverPassword,
});
// Update in-memory name immediately (other fields need reconnect)
const bot = this.bots.get(id);
@@ -128,15 +189,59 @@ export class BotManager {
}
async startBot(id: string): Promise {
- const bot = this.bots.get(id);
- if (!bot) throw new Error(`Bot ${id} not found`);
- await bot.connect();
+ const oldBot = this.bots.get(id);
+ if (!oldBot) throw new Error(`Bot ${id} not found`);
- // Mark as autoStart so it reconnects on Docker restart, and persist identity
+ // Always tear down the outgoing instance before creating a replacement.
+ // Covers three cases:
+ // 1. oldBot is fully connected (manual restart)
+ // 2. oldBot is mid-handshake from a prior rapid start (isConnected()
+ // still returns false but the library client is live and will leak
+ // a TS session if we abandon it)
+ // 3. oldBot was just created by createBot but never connected — the
+ // disconnect call is a cheap no-op here.
+ // Calling disconnect() is idempotent (disconnectEmitted guards event
+ // emission), so this is safe in all states.
+ oldBot.disconnect();
+
+ // Reload config from database so updated settings (channel, nickname, etc.) take effect
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
+ const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
+ const bot = new BotInstance({
+ id: saved.id,
+ name: saved.name,
+ tsOptions: {
+ host: saved.serverAddress,
+ port: saved.serverPort,
+ queryPort: proto === "ts6" ? 10080 : 10011,
+ nickname: saved.nickname,
+ // Reuse the stored identity so server groups assigned to this bot
+ // survive restarts — without this the TS server sees a new UID
+ // each connect and strips all previously granted groups.
+ identity: saved.identity || undefined,
+ defaultChannel: saved.defaultChannel || undefined,
+ channelPassword: saved.channelPassword || undefined,
+ serverPassword: saved.serverPassword || undefined,
+ serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
+ ts6ApiKey: saved.ts6ApiKey || undefined,
+ },
+ neteaseProvider: this.neteaseProvider,
+ qqProvider: this.qqProvider,
+ bilibiliProvider: this.bilibiliProvider,
+ youtubeProvider: this.youtubeProvider,
+ database: this.database,
+ config: this.config,
+ logger: this.logger,
+ });
+ this.bots.set(id, bot);
+ this.emit("botInstance", bot);
+ await connectWithTimeout(bot, 15_000, this.logger);
+ // Mark as autoStart so it reconnects on Docker restart, and persist identity
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
+ } else {
+ await connectWithTimeout(oldBot, 15_000, this.logger);
}
}
@@ -155,27 +260,33 @@ export class BotManager {
async loadSavedBots(): Promise {
const savedInstances = this.database.getBotInstances();
for (const saved of savedInstances) {
+ const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
- queryPort: 10011,
+ queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
+ serverPassword: saved.serverPassword || undefined,
+ serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
+ ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
+ youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(saved.id, bot);
+ this.emit("botInstance", bot);
// Only auto-connect bots that have autoStart enabled
if (saved.autoStart) {
diff --git a/src/data/database.test.ts b/src/data/database.test.ts
index 51da01a..38b140d 100644
--- a/src/data/database.test.ts
+++ b/src/data/database.test.ts
@@ -60,6 +60,9 @@ describe("database", () => {
defaultChannel: "Music",
channelPassword: "",
autoStart: true,
+ serverProtocol: "",
+ ts6ApiKey: "",
+ serverPassword: "",
};
botDb.saveBotInstance(instance);
@@ -86,6 +89,9 @@ describe("database", () => {
defaultChannel: "Music",
channelPassword: "",
autoStart: false,
+ serverProtocol: "",
+ ts6ApiKey: "",
+ serverPassword: "",
});
expect(botDb.deleteBotInstance("bot1")).toBe(true);
diff --git a/src/data/database.ts b/src/data/database.ts
index 3ece408..8e193e1 100644
--- a/src/data/database.ts
+++ b/src/data/database.ts
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
- platform: "netease" | "qq" | "bilibili";
+ platform: "netease" | "qq" | "bilibili" | "youtube";
coverUrl: string;
}
@@ -24,6 +24,12 @@ export interface BotInstance {
defaultChannel: string;
channelPassword: string;
autoStart: boolean;
+ /** "ts3" | "ts6" | "" (empty = auto-detect) */
+ serverProtocol: string;
+ /** API key for TS6 HTTP Query */
+ ts6ApiKey: string;
+ /** Password to join the TS server (server password) */
+ serverPassword: string;
identity?: string;
}
@@ -38,12 +44,20 @@ export interface BotDatabase {
}
function migrateSchema(db: Database.Database): void {
- // Add identity column if it doesn't exist (migration for existing databases)
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
- const hasIdentity = columns.some((c) => c.name === "identity");
- if (!hasIdentity) {
+ const names = columns.map((c) => c.name);
+ if (!names.includes("identity")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
+ if (!names.includes("serverProtocol")) {
+ db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
+ }
+ if (!names.includes("ts6ApiKey")) {
+ db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
+ }
+ if (!names.includes("serverPassword")) {
+ db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
+ }
}
function initTables(db: Database.Database): void {
@@ -69,6 +83,9 @@ function initTables(db: Database.Database): void {
defaultChannel TEXT NOT NULL,
channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0,
+ serverProtocol TEXT NOT NULL DEFAULT '',
+ ts6ApiKey TEXT NOT NULL DEFAULT '',
+ serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT
);
`);
@@ -90,8 +107,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
- INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
- VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
+ INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
+ VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
@@ -100,6 +117,9 @@ export function createDatabase(dbPath: string): BotDatabase {
defaultChannel = excluded.defaultChannel,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart,
+ serverProtocol = excluded.serverProtocol,
+ ts6ApiKey = excluded.ts6ApiKey,
+ serverPassword = excluded.serverPassword,
identity = excluded.identity
`);
@@ -133,6 +153,9 @@ export function createDatabase(dbPath: string): BotDatabase {
return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
+ serverProtocol: r.serverProtocol ?? "",
+ ts6ApiKey: r.ts6ApiKey ?? "",
+ serverPassword: r.serverPassword ?? "",
identity: r.identity ?? undefined,
}));
},
diff --git a/src/music/api-server.ts b/src/music/api-server.ts
index f517ea5..05fe3fc 100644
--- a/src/music/api-server.ts
+++ b/src/music/api-server.ts
@@ -17,7 +17,9 @@ export interface ApiServerManager {
function isPortFree(port: number): Promise {
return new Promise((resolve) => {
const server = net.createServer();
- server.once("error", () => resolve(false));
+ server.once("error", () => {
+ server.close(() => resolve(false));
+ });
server.once("listening", () => {
server.close(() => resolve(true));
});
diff --git a/src/music/provider.ts b/src/music/provider.ts
index 90b5f87..55a2c76 100644
--- a/src/music/provider.ts
+++ b/src/music/provider.ts
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
- platform: "netease" | "qq" | "bilibili";
+ platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface SongWithUrl extends Song {
@@ -17,7 +17,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
- platform: "netease" | "qq" | "bilibili";
+ platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface Album {
@@ -26,7 +26,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
- platform: "netease" | "qq" | "bilibili";
+ platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface LyricLine {
@@ -54,7 +54,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
- readonly platform: "netease" | "qq" | "bilibili";
+ readonly platform: "netease" | "qq" | "bilibili" | "youtube";
search(query: string, limit?: number): Promise;
getSongUrl(songId: string, quality?: string): Promise;
diff --git a/src/music/youtube.ts b/src/music/youtube.ts
new file mode 100644
index 0000000..6ae6a34
--- /dev/null
+++ b/src/music/youtube.ts
@@ -0,0 +1,232 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type {
+ MusicProvider,
+ Song,
+ SongWithUrl,
+ Playlist,
+ Album,
+ SearchResult,
+ LyricLine,
+ QrCodeResult,
+ AuthStatus,
+} from "./provider.js";
+
+const execFileAsync = promisify(execFile);
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */
+function findYtDlp(): string {
+ const exe = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
+ const candidates = [
+ join(__dirname, "..", "..", "bin", exe),
+ join(__dirname, "..", "..", "bin", "yt-dlp"),
+ exe,
+ ];
+ for (const c of candidates) {
+ // Absolute/relative paths: only return if the file exists.
+ // Bare names: return and let execFile resolve via PATH.
+ const isBinPath = c.includes(join("bin", "yt-dlp"));
+ if (!isBinPath || existsSync(c)) return c;
+ }
+ return exe;
+}
+
+/**
+ * Availability check for yt-dlp. Runs `yt-dlp --version` and caches only
+ * the positive result — if the binary is missing, subsequent calls retry
+ * so the user can install yt-dlp while the server is running and pick it
+ * up without a restart. Used by getAuthStatus() so the UI can reflect
+ * whether YouTube is actually usable.
+ */
+let cachedAvailable = false;
+let pendingCheck: Promise | null = null;
+async function checkYtDlpAvailable(): Promise {
+ if (cachedAvailable) return true;
+ if (pendingCheck) return pendingCheck;
+ pendingCheck = (async () => {
+ try {
+ await execFileAsync(findYtDlp(), ["--version"], {
+ timeout: 5_000,
+ maxBuffer: 1024,
+ });
+ cachedAvailable = true;
+ return true;
+ } catch {
+ return false;
+ } finally {
+ pendingCheck = null;
+ }
+ })();
+ return pendingCheck;
+}
+
+/** Force re-detection on the next call (for tests). */
+export function resetYtDlpAvailabilityCache(): void {
+ cachedAvailable = false;
+ pendingCheck = null;
+}
+
+async function runYtDlp(args: string[], timeoutMs = 30_000): Promise {
+ const binary = findYtDlp();
+ const env = { ...process.env };
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
+ // yt-dlp respects these env vars natively
+ }
+ const { stdout } = await execFileAsync(binary, args, {
+ timeout: timeoutMs,
+ env,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ return stdout;
+}
+
+interface YtDlpEntry {
+ id: string;
+ title: string;
+ uploader?: string;
+ channel?: string;
+ duration?: number;
+ thumbnail?: string;
+ webpage_url?: string;
+ url?: string;
+ entries?: YtDlpEntry[];
+ _type?: string;
+}
+
+function entryToSong(entry: YtDlpEntry): Song {
+ return {
+ id: entry.id ?? "",
+ name: entry.title ?? "Unknown",
+ artist: entry.uploader ?? entry.channel ?? "YouTube",
+ album: "YouTube",
+ duration: Math.round(entry.duration ?? 0),
+ coverUrl: entry.thumbnail ?? "",
+ platform: "youtube",
+ };
+}
+
+export class YouTubeProvider implements MusicProvider {
+ readonly platform = "youtube" as const;
+ private quality = "bestaudio";
+
+ async search(query: string, limit = 5): Promise {
+ try {
+ const raw = await runYtDlp([
+ `ytsearch${limit}:${query}`,
+ "--dump-json",
+ "--flat-playlist",
+ "--no-warnings",
+ "--quiet",
+ ]);
+ const lines = raw.trim().split("\n").filter(Boolean);
+ const songs: Song[] = lines.map((line) => {
+ const entry = JSON.parse(line) as YtDlpEntry;
+ return entryToSong(entry);
+ });
+ return { songs, playlists: [], albums: [] };
+ } catch {
+ return { songs: [], playlists: [], albums: [] };
+ }
+ }
+
+ async getSongUrl(songId: string): Promise {
+ try {
+ const url = `https://www.youtube.com/watch?v=${songId}`;
+ const raw = await runYtDlp([
+ url,
+ "--get-url",
+ "-f",
+ "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio",
+ "--no-warnings",
+ "--quiet",
+ ], 45_000);
+ const audioUrl = raw.trim().split("\n")[0];
+ return audioUrl || null;
+ } catch {
+ return null;
+ }
+ }
+
+ setQuality(quality: string): void {
+ this.quality = quality;
+ }
+
+ getQuality(): string {
+ return this.quality;
+ }
+
+ async getSongDetail(songId: string): Promise {
+ try {
+ const url = `https://www.youtube.com/watch?v=${songId}`;
+ const raw = await runYtDlp([url, "--dump-json", "--no-warnings", "--quiet"]);
+ const entry = JSON.parse(raw.trim()) as YtDlpEntry;
+ return entryToSong(entry);
+ } catch {
+ return null;
+ }
+ }
+
+ async getPlaylistSongs(playlistId: string): Promise {
+ try {
+ const url = playlistId.startsWith("http")
+ ? playlistId
+ : `https://www.youtube.com/playlist?list=${playlistId}`;
+ const raw = await runYtDlp([
+ url,
+ "--dump-json",
+ "--flat-playlist",
+ "--no-warnings",
+ "--quiet",
+ ], 60_000);
+ const lines = raw.trim().split("\n").filter(Boolean);
+ return lines.map((line) => entryToSong(JSON.parse(line) as YtDlpEntry));
+ } catch {
+ return [];
+ }
+ }
+
+ async getRecommendPlaylists(): Promise {
+ return [];
+ }
+
+ async getAlbumSongs(_albumId: string): Promise {
+ return [];
+ }
+
+ async getLyrics(_songId: string): Promise {
+ return [];
+ }
+
+ async getQrCode(): Promise {
+ return { qrUrl: "", key: "" };
+ }
+
+ async checkQrCodeStatus(
+ _key: string
+ ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
+ return "expired";
+ }
+
+ setCookie(_cookie: string): void {}
+ getCookie(): string { return ""; }
+
+ async getAuthStatus(): Promise {
+ // YouTube has no login concept via yt-dlp, so "loggedIn" here means
+ // "yt-dlp binary is reachable and responds to --version". The UI can
+ // use this flag to grey out YouTube when the optional dependency is
+ // missing, instead of silently returning empty search results.
+ const available = await checkYtDlpAvailable();
+ if (available) {
+ return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
+ }
+ return {
+ loggedIn: false,
+ nickname: "YouTube (yt-dlp not installed)",
+ };
+ }
+}
diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts
index a3bb428..fdf2f1a 100644
--- a/src/ts-protocol/client.ts
+++ b/src/ts-protocol/client.ts
@@ -12,18 +12,28 @@ import {
type ClientInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
+import {
+ detectServerProtocol,
+ type ServerProtocol,
+} from "./protocol-detect.js";
+import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
+export type { ServerProtocol } from "./protocol-detect.js";
export interface TS3ClientOptions {
host: string;
port: number; // Voice/virtual server port (default 9987)
- queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat
+ queryPort: number; // ServerQuery port (10011 for TS3, 10080 for TS6 HTTP)
nickname: string;
identity?: string; // Exported identity string, or undefined to generate new
defaultChannel?: string;
channelPassword?: string;
serverPassword?: string;
+ /** Force a specific protocol instead of auto-detecting. */
+ serverProtocol?: ServerProtocol;
+ /** API key for TS6 HTTP Query authentication. */
+ ts6ApiKey?: string;
}
export interface TS3TextMessage {
@@ -40,6 +50,9 @@ export class TS3Client extends EventEmitter {
private clientId = 0;
private logger: Logger;
private disconnecting = false;
+ private detectedProtocol: ServerProtocol = "unknown";
+ private httpQuery: TS6HttpQuery | null = null;
+ private udpErrorTimer: ReturnType | null = null;
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
@@ -52,6 +65,16 @@ export class TS3Client extends EventEmitter {
}
}
+ /** The detected (or forced) server protocol after connect(). */
+ getServerProtocol(): ServerProtocol {
+ return this.detectedProtocol;
+ }
+
+ /** TS6 HTTP Query client (available after connecting to a TS6 server). */
+ getHttpQuery(): TS6HttpQuery | null {
+ return this.httpQuery;
+ }
+
async connect(): Promise {
// Clean up any existing connection before creating a new one
if (this.client) {
@@ -66,23 +89,81 @@ export class TS3Client extends EventEmitter {
}
const addr = `${this.options.host}:${this.options.port}`;
- this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
+
+ // Detect or use forced protocol
+ if (this.options.serverProtocol && this.options.serverProtocol !== "unknown") {
+ this.detectedProtocol = this.options.serverProtocol;
+ this.logger.info(
+ { addr, protocol: this.detectedProtocol },
+ "Using forced server protocol",
+ );
+ } else {
+ this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)...");
+ const detection = await detectServerProtocol(
+ this.options.host,
+ this.options.port,
+ 3000,
+ { ts3QueryPort: 10011, ts6HttpPort: 10080 },
+ );
+ this.detectedProtocol = detection.protocol;
+ if (this.detectedProtocol === "unknown") {
+ this.logger.warn(
+ { addr },
+ "Could not detect server protocol (query ports 10011/10080 unreachable). " +
+ "Will attempt voice connection anyway. Use serverProtocol option to force TS3 or TS6.",
+ );
+ } else {
+ this.logger.info(
+ { addr, protocol: this.detectedProtocol, queryPort: detection.queryPort },
+ `Server protocol detected: ${this.detectedProtocol.toUpperCase()}`,
+ );
+ }
+ }
+
+ // Set up TS6 HTTP Query if applicable
+ if (this.detectedProtocol === "ts6") {
+ const queryPort = this.options.queryPort !== 10011 ? this.options.queryPort : 10080;
+ this.httpQuery = new TS6HttpQuery({
+ host: this.options.host,
+ port: queryPort,
+ apiKey: this.options.ts6ApiKey,
+ });
+ }
+
+ // Guard against calling connect() while already connected.
+ // Save detectedProtocol first because disconnect() resets it.
+ if (this.client) {
+ this.logger.warn("connect() called while already connected, disconnecting first");
+ const savedProtocol = this.detectedProtocol;
+ const savedHttpQuery = this.httpQuery;
+ this.disconnect();
+ this.detectedProtocol = savedProtocol;
+ this.httpQuery = savedHttpQuery;
+ // Give the old client a moment to tear down
+ await new Promise((r) => setTimeout(r, 100));
+ }
+
+ this.logger.info(
+ { addr, protocol: this.detectedProtocol },
+ "Connecting to TeamSpeak server (full client protocol)",
+ );
// Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks)
let udpErrorCount = 0;
- let udpErrorTimer: ReturnType | null = null;
const throttledWarn = (msg: string, ...args: unknown[]) => {
if (typeof msg === "string" && msg.includes("udp send error")) {
udpErrorCount++;
if (udpErrorCount === 1) {
this.logger.warn(msg);
- // After 2 seconds, log a summary and reset
- udpErrorTimer = setTimeout(() => {
+ // After 2 seconds, log a summary and reset.
+ // Clear any previous timer to avoid leaking it.
+ if (this.udpErrorTimer) clearTimeout(this.udpErrorTimer);
+ this.udpErrorTimer = setTimeout(() => {
if (udpErrorCount > 1) {
this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`);
}
udpErrorCount = 0;
- udpErrorTimer = null;
+ this.udpErrorTimer = null;
}, 2000);
}
return;
@@ -124,10 +205,19 @@ export class TS3Client extends EventEmitter {
});
await this.client.connect();
+ // Note: @honeybbq/teamspeak-client 0.2.x ships a universal clientinit
+ // (client_version "3.?.? [Build: 5680278000]" + matching signature)
+ // that works against both TS3 and TS6 servers. The old 3.6.2 monkey-
+ // patch on handler.sendPacket was removed when we bumped to 0.2.1 — it
+ // would have replaced the library's new correct version with a stale
+ // signature and made TS6 handshakes fail.
await this.client.waitConnected();
this.clientId = this.client.clientID();
this.voiceFramesSent = 0;
- this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
+ this.logger.info(
+ { clientId: this.clientId, protocol: this.detectedProtocol },
+ `Logged in (visible client, ${this.detectedProtocol.toUpperCase()} server)`,
+ );
// Join default channel if specified
if (this.options.defaultChannel) {
@@ -225,6 +315,12 @@ export class TS3Client extends EventEmitter {
});
}
this.clientId = 0;
+ this.httpQuery = null;
+ this.detectedProtocol = "unknown";
+ if (this.udpErrorTimer) {
+ clearTimeout(this.udpErrorTimer);
+ this.udpErrorTimer = null;
+ }
this.logger.info("Disconnected from TeamSpeak server");
}
}
diff --git a/src/ts-protocol/connection.ts b/src/ts-protocol/connection.ts
index 135453c..065c66c 100644
--- a/src/ts-protocol/connection.ts
+++ b/src/ts-protocol/connection.ts
@@ -1,10 +1,17 @@
+/**
+ * TS3 raw-TCP ServerQuery connection (port 10011).
+ *
+ * @deprecated This module only works with TS3 servers. TS6 servers replaced
+ * the raw-TCP ServerQuery with HTTP/HTTPS (port 10080/10443) and SSH (10022).
+ * For TS6 servers, use {@link ../http-query.js TS6HttpQuery} instead.
+ */
import net from "node:net";
import { EventEmitter } from "node:events";
import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js";
export interface ConnectionOptions {
host: string;
- port: number; // ServerQuery port, typically 10011
+ port: number; // ServerQuery port: 10011 (TS3) — not available on TS6
}
export interface CommandResult {
diff --git a/src/ts-protocol/http-query.ts b/src/ts-protocol/http-query.ts
new file mode 100644
index 0000000..a0af0cd
--- /dev/null
+++ b/src/ts-protocol/http-query.ts
@@ -0,0 +1,179 @@
+import http from "node:http";
+import https from "node:https";
+
+export interface HttpQueryOptions {
+ host: string;
+ port: number; // 10080 (HTTP) or 10443 (HTTPS)
+ useTls?: boolean;
+ apiKey?: string;
+ timeoutMs?: number;
+}
+
+export interface HttpQueryResult {
+ status: number;
+ body: unknown;
+}
+
+/**
+ * TS6 HTTP Query client.
+ *
+ * TeamSpeak 6 Server replaces the TS3 raw-TCP ServerQuery (port 10011)
+ * with an HTTP/HTTPS API on ports 10080/10443.
+ *
+ * Common endpoints (TS6 HTTP Query):
+ * GET / → server info / health check
+ * POST /api-key → create API key
+ * GET /1/serverlist → list virtual servers
+ * GET /1/clientlist?sid={sid} → list clients
+ * POST /1/sendtextmessage → send text message
+ * POST /1/clientmove → move a client
+ * GET /1/channellist?sid={sid} → list channels
+ * POST /1/clientupdate → update client properties
+ */
+export class TS6HttpQuery {
+ private options: Required;
+
+ constructor(options: HttpQueryOptions) {
+ this.options = {
+ host: options.host,
+ port: options.port,
+ useTls: options.useTls ?? options.port === 10443,
+ apiKey: options.apiKey ?? "",
+ timeoutMs: options.timeoutMs ?? 5000,
+ };
+ }
+
+ async request(
+ method: "GET" | "POST" | "PUT" | "DELETE",
+ path: string,
+ body?: Record,
+ ): Promise {
+ const { host, port, useTls, apiKey, timeoutMs } = this.options;
+ const transport = useTls ? https : http;
+
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ if (apiKey) {
+ headers["x-api-key"] = apiKey;
+ }
+
+ let bodyStr: string | undefined;
+ if (body) {
+ bodyStr = JSON.stringify(body);
+ headers["Content-Type"] = "application/json";
+ headers["Content-Length"] = String(Buffer.byteLength(bodyStr));
+ }
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const fail = (err: Error) => {
+ if (settled) return;
+ settled = true;
+ reject(err);
+ };
+
+ const req = transport.request(
+ {
+ hostname: host,
+ port,
+ path,
+ method,
+ timeout: timeoutMs,
+ headers,
+ rejectUnauthorized: false, // self-signed certs common on self-hosted
+ },
+ (res) => {
+ let data = "";
+ res.setEncoding("utf-8");
+ res.on("data", (chunk: string) => (data += chunk));
+ res.on("error", fail);
+ res.on("end", () => {
+ if (settled) return;
+ settled = true;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ parsed = data;
+ }
+ resolve({
+ status: res.statusCode ?? 0,
+ body: parsed,
+ });
+ });
+ },
+ );
+
+ req.on("error", fail);
+ req.on("timeout", () => {
+ req.destroy();
+ fail(new Error("TS6 HTTP Query timeout"));
+ });
+
+ if (bodyStr) {
+ req.write(bodyStr);
+ }
+ req.end();
+ });
+ }
+
+ /** Check if the TS6 HTTP Query is reachable */
+ async healthCheck(): Promise {
+ try {
+ const result = await this.request("GET", "/");
+ return result.status >= 200 && result.status < 500;
+ } catch {
+ return false;
+ }
+ }
+
+ /** List virtual servers */
+ async serverList(): Promise {
+ return this.request("GET", "/1/serverlist");
+ }
+
+ /** List clients on a virtual server */
+ async clientList(sid = 1): Promise {
+ return this.request("GET", `/1/clientlist?sid=${sid}`);
+ }
+
+ /** List channels on a virtual server */
+ async channelList(sid = 1): Promise {
+ return this.request("GET", `/1/channellist?sid=${sid}`);
+ }
+
+ /** Send a text message */
+ async sendTextMessage(
+ targetMode: number,
+ target: number,
+ msg: string,
+ sid = 1,
+ ): Promise {
+ return this.request("POST", `/1/sendtextmessage?sid=${sid}`, {
+ targetmode: targetMode,
+ target,
+ msg,
+ });
+ }
+
+ /** Update client properties (e.g., description) */
+ async clientUpdate(
+ properties: Record,
+ sid = 1,
+ ): Promise {
+ return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
+ }
+
+ /** Move a client to a channel */
+ async clientMove(
+ clid: number,
+ cid: number,
+ cpw?: string,
+ sid = 1,
+ ): Promise {
+ const body: Record = { clid, cid };
+ if (cpw) body.cpw = cpw;
+ return this.request("POST", `/1/clientmove?sid=${sid}`, body);
+ }
+}
diff --git a/src/ts-protocol/protocol-detect.test.ts b/src/ts-protocol/protocol-detect.test.ts
new file mode 100644
index 0000000..46fdb86
--- /dev/null
+++ b/src/ts-protocol/protocol-detect.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest";
+import {
+ detectServerProtocol,
+ type ServerProtocol,
+ type ProtocolDetectResult,
+} from "./protocol-detect.js";
+
+describe("protocol-detect", () => {
+ it("returns unknown for unreachable hosts", async () => {
+ const result = await detectServerProtocol("192.0.2.1", 9987, 1000);
+ expect(result.protocol).toBe("unknown");
+ expect(result.queryPort).toBeNull();
+ expect(result.voicePort).toBe(9987);
+ });
+
+ it("result shape matches ProtocolDetectResult interface", async () => {
+ const result = await detectServerProtocol("127.0.0.1", 9987, 500);
+ expect(result).toHaveProperty("protocol");
+ expect(result).toHaveProperty("queryPort");
+ expect(result).toHaveProperty("voicePort");
+ expect(["ts3", "ts6", "unknown"]).toContain(result.protocol);
+ });
+});
diff --git a/src/ts-protocol/protocol-detect.ts b/src/ts-protocol/protocol-detect.ts
new file mode 100644
index 0000000..b58b1d5
--- /dev/null
+++ b/src/ts-protocol/protocol-detect.ts
@@ -0,0 +1,128 @@
+import net from "node:net";
+import http from "node:http";
+
+export type ServerProtocol = "ts3" | "ts6" | "unknown";
+
+export interface ProtocolDetectResult {
+ protocol: ServerProtocol;
+ /** The query port that responded (10011 for TS3, 10080 for TS6 HTTP) */
+ queryPort: number | null;
+ /** Whether the voice port (UDP 9987) is the same for both */
+ voicePort: number;
+}
+
+export interface DetectOptions {
+ /** TS3 ServerQuery probe port (default: 10011) */
+ ts3QueryPort?: number;
+ /** TS6 HTTP Query probe port (default: 10080) */
+ ts6HttpPort?: number;
+}
+
+/**
+ * Probe a TeamSpeak server to determine if it's running TS3 or TS6.
+ *
+ * Detection strategy:
+ * 1. Try TCP connect to port 10011 (TS3 ServerQuery) — if banner starts with "TS3", it's TS3.
+ * 2. Try HTTP GET to port 10080 (TS6 HTTP Query) — if we get a valid HTTP response, it's TS6.
+ * 3. If neither responds, return "unknown" (voice-only connection may still work).
+ */
+export async function detectServerProtocol(
+ host: string,
+ voicePort = 9987,
+ timeoutMs = 3000,
+ options?: DetectOptions,
+): Promise {
+ const ts3Port = options?.ts3QueryPort ?? 10011;
+ const ts6Port = options?.ts6HttpPort ?? 10080;
+
+ const [ts3, ts6] = await Promise.allSettled([
+ probeTS3Query(host, ts3Port, timeoutMs),
+ probeTS6HttpQuery(host, ts6Port, timeoutMs),
+ ]);
+
+ // Prefer TS3 if both somehow respond (shouldn't happen in practice)
+ if (ts3.status === "fulfilled" && ts3.value) {
+ return { protocol: "ts3", queryPort: ts3Port, voicePort };
+ }
+ if (ts6.status === "fulfilled" && ts6.value) {
+ return { protocol: "ts6", queryPort: ts6Port, voicePort };
+ }
+
+ return { protocol: "unknown", queryPort: null, voicePort };
+}
+
+/**
+ * Probe TS3 ServerQuery by connecting to raw TCP and checking for "TS3" banner.
+ */
+function probeTS3Query(host: string, port: number, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: boolean) => {
+ if (resolved) return;
+ resolved = true;
+ socket.removeAllListeners();
+ socket.destroy();
+ resolve(value);
+ };
+
+ const socket = net.createConnection({ host, port, timeout: timeoutMs });
+ let banner = "";
+ const MAX_BANNER = 256; // TS3 banner is ~50 bytes; cap to avoid memory abuse
+
+ socket.setTimeout(timeoutMs);
+
+ socket.on("data", (data: Buffer) => {
+ banner += data.toString("utf-8");
+ if (banner.length > MAX_BANNER) banner = banner.slice(0, MAX_BANNER);
+ if (banner.includes("TS3")) {
+ done(true);
+ }
+ });
+
+ socket.on("connect", () => {
+ // Wait briefly for banner after TCP connect
+ setTimeout(() => done(banner.includes("TS3")), 500);
+ });
+
+ socket.on("error", () => done(false));
+ socket.on("timeout", () => done(false));
+ });
+}
+
+/**
+ * Probe TS6 HTTP Query by sending GET / and checking for a valid response.
+ * Any HTTP status (including 401/403) confirms the TS6 HTTP Query exists.
+ */
+function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: boolean) => {
+ if (resolved) return;
+ resolved = true;
+ resolve(value);
+ };
+
+ const req = http.request(
+ {
+ hostname: host,
+ port,
+ path: "/",
+ method: "GET",
+ timeout: timeoutMs,
+ headers: { Accept: "application/json" },
+ },
+ (res) => {
+ res.resume();
+ done(res.statusCode !== undefined);
+ },
+ );
+
+ req.on("error", () => done(false));
+ req.on("timeout", () => {
+ req.destroy();
+ done(false);
+ });
+
+ req.end();
+ });
+}
diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts
index 244618e..68fc323 100644
--- a/src/web/api/auth.ts
+++ b/src/web/api/auth.ts
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
+import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
@@ -11,9 +12,13 @@ export function createAuthRouter(
cookieStore?: CookieStore
): Router {
const router = Router();
+ // YouTube is auth-less; we only use this instance so /auth/status can
+ // report whether yt-dlp is actually installed (loggedIn=false otherwise).
+ const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
+ if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -119,6 +124,14 @@ export function createAuthRouter(
res.status(400).json({ error: "cookie is required" });
return;
}
+ // YouTube has no cookie concept — reject instead of falling through and
+ // clobbering the NetEase cookie entry.
+ if (platform === "youtube") {
+ res
+ .status(400)
+ .json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
+ return;
+ }
const provider = getProvider(platform);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts
index 2d4d51b..e60bece 100644
--- a/src/web/api/bot.ts
+++ b/src/web/api/bot.ts
@@ -43,6 +43,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
+ serverPassword,
autoStart,
} = req.body;
if (!name || !serverAddress || !nickname) {
@@ -58,6 +59,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
+ serverPassword,
autoStart: autoStart ?? false,
});
res.status(201).json(bot.getStatus());
@@ -75,10 +77,10 @@ export function createBotRouter(
res.status(404).json({ error: "Bot not found" });
return;
}
- const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body;
+ const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
// Update in database
botManager.updateBot(req.params.id, {
- name, serverAddress, serverPort, nickname, defaultChannel, channelPassword,
+ name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
});
res.json({ success: true });
} catch (err) {
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index e1ef73b..3690674 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
+import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
export function createMusicRouter(
@@ -9,9 +10,11 @@ export function createMusicRouter(
logger: Logger
): Router {
const router = Router();
+ const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
+ if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index bd82b61..4cd55b3 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -25,6 +25,14 @@ export function createPlayerRouter(
next();
});
+ /** Map API platform string to the corresponding command flag. */
+ const platformFlag = (platform: unknown): string => {
+ if (platform === "bilibili") return "-b";
+ if (platform === "qq") return "-q";
+ if (platform === "youtube") return "-y";
+ return "";
+ };
+
router.post("/:botId/play", async (req, res) => {
try {
const bot = (req as any).bot;
@@ -33,8 +41,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "query is required" });
return;
}
- const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
- const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!");
+ const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
@@ -50,8 +57,7 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
- const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
- const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!");
+ const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
@@ -85,7 +91,21 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { volume } = req.body;
- const cmd = parseCommand(`!vol ${volume}`, "!")!;
+ // Reject bad input with a proper 4xx instead of letting cmdVol
+ // return a "Usage:" string inside a 200 body — API clients can't
+ // detect that failure mode, and the UI would silently swallow it.
+ if (
+ typeof volume !== "number" ||
+ !Number.isFinite(volume) ||
+ volume < 0 ||
+ volume > 100
+ ) {
+ res
+ .status(400)
+ .json({ error: "volume must be a number between 0 and 100" });
+ return;
+ }
+ const cmd = parseCommand(`!vol ${Math.round(volume)}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
@@ -93,10 +113,18 @@ export function createPlayerRouter(
}
});
+ const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
+
router.post("/:botId/mode", async (req, res) => {
try {
const bot = (req as any).bot;
const { mode } = req.body;
+ if (typeof mode !== "string" || !VALID_MODES.has(mode)) {
+ res
+ .status(400)
+ .json({ error: "mode must be one of: seq, loop, random, rloop" });
+ return;
+ }
const cmd = parseCommand(`!mode ${mode}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
@@ -116,8 +144,12 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
- if (typeof position !== "number" || position < 0) {
- res.status(400).json({ error: "position (seconds) is required" });
+ // typeof NaN === "number" and NaN < 0 is false, so a plain range
+ // check lets NaN/Infinity through and later corrupts seekOffset.
+ if (typeof position !== "number" || !Number.isFinite(position) || position < 0) {
+ res
+ .status(400)
+ .json({ error: "position must be a finite non-negative number" });
return;
}
bot.getPlayer().seek(position);
@@ -153,8 +185,15 @@ export function createPlayerRouter(
return;
}
const queue = bot.getQueueManager();
- bot.getPlayer().stop(); // Stop current playback first
- bot.getPlayer().resetFailures(); // Reset on user-initiated play
+ // Validate the index BEFORE stopping current playback — otherwise an
+ // invalid index silently kills the user's current song and leaves the
+ // queue idle.
+ if (index >= queue.size()) {
+ res.status(400).json({ error: "Invalid queue index" });
+ return;
+ }
+ bot.getPlayer().stop();
+ bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
@@ -175,9 +214,8 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
- const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(
- `!playlist ${flags} ${playlistId}`.trim(),
+ `!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
@@ -193,11 +231,13 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
- const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
- if (!provider) {
- res.status(500).json({ error: "Provider not available" });
- 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
+ : "netease"
+ );
// Stop current playback
bot.getPlayer().stop();
@@ -241,11 +281,11 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
- const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
- if (!provider) {
- res.status(500).json({ error: "Provider not available" });
- return;
- }
+ const provider = bot.getProviderFor(
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
+ ? platform
+ : "netease"
+ );
const song = await provider.getSongDetail(songId);
if (!song) {
@@ -276,11 +316,11 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
- const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
- if (!provider) {
- res.status(500).json({ error: "Provider not available" });
- return;
- }
+ const provider = bot.getProviderFor(
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
+ ? platform
+ : "netease"
+ );
const song = await provider.getSongDetail(songId);
if (!song) {
diff --git a/src/web/websocket.ts b/src/web/websocket.ts
index fc8813d..3832ab0 100644
--- a/src/web/websocket.ts
+++ b/src/web/websocket.ts
@@ -10,8 +10,9 @@ export function setupWebSocket(
): () => void {
const clients = new Set();
- /** Track which bots already have listeners attached */
+ /** Track which bot instances have listeners attached (keyed by id, storing ref) */
const attachedBots = new Map void;
connected: () => void;
disconnected: () => void;
@@ -48,8 +49,22 @@ export function setupWebSocket(
}
};
+ function detachBotListener(id: string): void {
+ const existing = attachedBots.get(id);
+ if (!existing) return;
+ existing.bot.removeListener("stateChange", existing.stateChange);
+ existing.bot.removeListener("connected", existing.connected);
+ existing.bot.removeListener("disconnected", existing.disconnected);
+ attachedBots.delete(id);
+ }
+
function attachBotListener(bot: BotInstance): void {
- if (attachedBots.has(bot.id)) return;
+ const existing = attachedBots.get(bot.id);
+ if (existing) {
+ if (existing.bot === bot) return; // already attached to this instance
+ // Bot instance was replaced (e.g. startBot re-created it) — re-attach
+ detachBotListener(bot.id);
+ }
const onStateChange = () => {
broadcast({
@@ -81,6 +96,7 @@ export function setupWebSocket(
bot.on("disconnected", onDisconnected);
attachedBots.set(bot.id, {
+ bot,
stateChange: onStateChange,
connected: onConnected,
disconnected: onDisconnected,
@@ -94,21 +110,39 @@ export function setupWebSocket(
}
}
- // Check for newly added bots periodically
- const intervalId = setInterval(ensureAllBotsAttached, 5000);
+ // React immediately when a bot instance is created or replaced
+ const onBotInstance = (bot: BotInstance) => attachBotListener(bot);
+ botManager.on("botInstance", onBotInstance);
+
+ // React when a bot is removed: detach its listener and tell clients to drop it
+ const onBotInstanceRemoved = (id: string) => {
+ detachBotListener(id);
+ broadcast({ type: "botRemoved", botId: id });
+ };
+ botManager.on("botInstanceRemoved", onBotInstanceRemoved);
+
+ /** Drop attached listeners whose bot is no longer in the manager. */
+ function reconcileAttachedBots(): void {
+ const liveIds = new Set(botManager.getAllBots().map((b) => b.id));
+ for (const id of Array.from(attachedBots.keys())) {
+ if (!liveIds.has(id)) detachBotListener(id);
+ }
+ }
+
+ // Safety net: periodically re-check in case any bot was missed
+ const intervalId = setInterval(() => {
+ reconcileAttachedBots();
+ ensureAllBotsAttached();
+ }, 5000);
ensureAllBotsAttached();
return () => {
clearInterval(intervalId);
- // Clean up named listeners
- for (const bot of botManager.getAllBots()) {
- const listeners = attachedBots.get(bot.id);
- if (listeners) {
- bot.removeListener("stateChange", listeners.stateChange);
- bot.removeListener("connected", listeners.connected);
- bot.removeListener("disconnected", listeners.disconnected);
- }
+ botManager.removeListener("botInstance", onBotInstance);
+ botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
+ // Clean up all attached listeners (detach from stored bot refs, not live map)
+ for (const id of Array.from(attachedBots.keys())) {
+ detachBotListener(id);
}
- attachedBots.clear();
};
}
diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue
index 006a51e..10d5eb7 100644
--- a/web/src/components/Navbar.vue
+++ b/web/src/components/Navbar.vue
@@ -9,36 +9,51 @@
-
-
+
+
-
+
+
+
+
+
+
点击切换 · 🔗 复制专属链接
-
-
- {{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
-
-
@@ -55,12 +70,36 @@ const store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false);
const selectorRef = ref
(null);
+const togglingBots = ref>({});
function selectBot(id: string) {
store.setActiveBotId(id);
dropdownOpen.value = false;
}
+function copyBotLink(id: string) {
+ const url = `${window.location.origin}/bot/${id}`;
+ navigator.clipboard.writeText(url).then(() => {
+ dropdownOpen.value = false;
+ });
+}
+
+async function togglePower(bot: { id: string; connected: boolean; name: string }) {
+ if (togglingBots.value[bot.id]) return;
+ togglingBots.value[bot.id] = true;
+ try {
+ if (bot.connected) {
+ await store.stopBotInstance(bot.id);
+ } else {
+ await store.startBotInstance(bot.id);
+ }
+ } catch (err) {
+ console.error(`Failed to toggle bot ${bot.name}`, err);
+ } finally {
+ togglingBots.value[bot.id] = false;
+ }
+}
+
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
dropdownOpen.value = false;
@@ -145,22 +184,31 @@ onUnmounted(() => {
.bot-selector-btn {
display: flex;
align-items: center;
- gap: 8px;
- padding: 6px 14px;
+ gap: 10px;
+ padding: 10px 20px;
background: var(--hover-bg);
- border-radius: var(--radius-sm);
- font-size: 13px;
- font-weight: 500;
- transition: background var(--transition-fast);
+ border-radius: var(--radius-md);
+ font-size: 16px;
+ font-weight: 600;
+ min-height: 44px;
+ border: 1px solid var(--border-color);
+ transition: background var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--bg-card);
+ border-color: var(--color-primary);
}
}
+.bot-state-mini {
+ font-size: 14px;
+ &.playing { color: #22c55e; }
+ &.paused { color: #eab308; }
+}
+
.bot-chevron {
- font-size: 16px;
+ font-size: 20px;
opacity: 0.5;
transition: transform 0.2s ease;
@@ -170,8 +218,8 @@ onUnmounted(() => {
}
.bot-dot {
- width: 8px;
- height: 8px;
+ width: 10px;
+ height: 10px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
@@ -182,7 +230,7 @@ onUnmounted(() => {
}
.bot-selector-name {
- max-width: 120px;
+ max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -205,7 +253,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 8px;
- width: 100%;
+ flex: 1;
+ min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
@@ -222,6 +271,12 @@ onUnmounted(() => {
}
}
+.bot-dropdown-row {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
.bot-dropdown-name {
flex: 1;
min-width: 0;
@@ -230,6 +285,47 @@ onUnmounted(() => {
white-space: nowrap;
}
+.bot-link-btn {
+ flex-shrink: 0;
+ padding: 6px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 15px;
+ opacity: 0.4;
+ transition: opacity var(--transition-fast), background var(--transition-fast);
+ cursor: pointer;
+
+ &:hover {
+ opacity: 1;
+ background: var(--hover-bg);
+ }
+}
+
+.bot-power-btn {
+ flex-shrink: 0;
+ padding: 6px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 16px;
+ opacity: 0.5;
+ color: var(--text-tertiary);
+ transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ opacity: 1;
+ background: var(--hover-bg);
+ }
+
+ &:disabled {
+ opacity: 0.25;
+ cursor: wait;
+ }
+
+ &.online {
+ color: #22c55e;
+ opacity: 0.9;
+ }
+}
+
.bot-playing-badge,
.bot-paused-badge,
.bot-idle-badge,
@@ -261,8 +357,21 @@ onUnmounted(() => {
color: var(--text-tertiary);
}
+.bot-dropdown-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 4px 0;
+}
+
+.bot-dropdown-hint {
+ padding: 4px 12px 6px;
+ font-size: 11px;
+ color: var(--text-tertiary);
+ text-align: center;
+}
+
.settings-btn {
- font-size: 20px;
+ font-size: 22px;
opacity: 0.6;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue
index ce6d6ae..35e48fc 100644
--- a/web/src/components/SongCard.vue
+++ b/web/src/components/SongCard.vue
@@ -7,8 +7,8 @@
{{ song.name }}
{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}
+ :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' : '网易云' }}
{{ song.artist }}
@@ -118,6 +118,11 @@ function formatDuration(seconds: number): string {
color: #00a1d6;
}
+.badge-youtube {
+ background: rgba(255, 0, 0, 0.12);
+ color: #ff0000;
+}
+
.song-artist {
font-size: 12px;
color: var(--text-secondary);
diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts
index b343cc2..3979060 100644
--- a/web/src/composables/useWebSocket.ts
+++ b/web/src/composables/useWebSocket.ts
@@ -56,6 +56,13 @@ export function useWebSocket() {
}
}
break;
+ case 'botRemoved':
+ // Bot was deleted from the server — drop from local state entirely
+ store.removeBotStatus(data.botId);
+ if (store.activeBotId === data.botId) {
+ store.activeBotId = store.bots[0]?.id ?? null;
+ }
+ break;
}
};
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index 66da02a..356d57f 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -38,6 +38,12 @@ const router = createRouter({
name: 'setup',
component: () => import('../views/Setup.vue'),
},
+ {
+ // Per-bot URL: /bot/:id — sets active bot then redirects to home
+ path: '/bot/:id',
+ name: 'bot',
+ component: () => import('../views/BotRedirect.vue'),
+ },
],
});
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index b350da1..7161745 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -168,6 +168,14 @@ export const usePlayerStore = defineStore('player', {
if (saved) this.theme = saved;
},
+ async startBotInstance(id: string) {
+ await axios.post(`/api/bot/${id}/start`);
+ },
+
+ async stopBotInstance(id: string) {
+ await axios.post(`/api/bot/${id}/stop`);
+ },
+
async fetchBots() {
const res = await axios.get('/api/bot');
this.bots = res.data.bots;
diff --git a/web/src/views/BotRedirect.vue b/web/src/views/BotRedirect.vue
new file mode 100644
index 0000000..3378ac4
--- /dev/null
+++ b/web/src/views/BotRedirect.vue
@@ -0,0 +1,42 @@
+
+
+ 机器人不存在或未加载
+ 正在跳转...
+
+
+
+
+
+
diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue
index 474f8a5..7bb2128 100644
--- a/web/src/views/Settings.vue
+++ b/web/src/views/Settings.vue
@@ -76,6 +76,10 @@
+
+
+
+
@@ -108,6 +112,10 @@
+
+
+
+
@@ -394,6 +402,7 @@ const newBotServer = ref('');
const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
+const newBotServerPassword = ref('');
// Edit bot
const editingBot = ref(null);
@@ -404,6 +413,7 @@ const editForm = reactive({
nickname: '',
defaultChannel: '',
channelPassword: '',
+ serverPassword: '',
});
const neteaseCookie = ref('');
@@ -559,6 +569,7 @@ async function createBot() {
serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined,
+ serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
newBotName.value = '';
@@ -566,6 +577,7 @@ async function createBot() {
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
+ newBotServerPassword.value = '';
await store.fetchBots();
} catch {
// Ignore
@@ -598,6 +610,7 @@ async function openEditBot(bot: any) {
editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelPassword = res.data.channelPassword ?? '';
+ editForm.serverPassword = res.data.serverPassword ?? '';
} catch {
// Config not found — use defaults
editForm.serverAddress = '';
@@ -605,6 +618,7 @@ async function openEditBot(bot: any) {
editForm.nickname = bot.name;
editForm.defaultChannel = '';
editForm.channelPassword = '';
+ editForm.serverPassword = '';
}
}