mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a0ae5f2c | ||
|
|
a56dc5bc85 | ||
|
|
1a663156c3 | ||
|
|
e92628a6b4 | ||
|
|
b4ad78ce84 | ||
|
|
30aced6d36 | ||
|
|
da5b34f5f4 | ||
|
|
010aa8aa8c | ||
|
|
05edca4a9a | ||
|
|
e2b1a5e055 | ||
|
|
4eac2e4dde | ||
|
|
78380f27d4 | ||
|
|
47e0d0f288 | ||
|
|
dab02ede9c | ||
|
|
c950c7402f | ||
|
|
c86d70ca08 | ||
|
|
d7107677c0 | ||
|
|
5647cf6d36 | ||
|
|
3aa06006fe | ||
|
|
ac9f447ad2 | ||
|
|
5692d043c9 | ||
|
|
49879f987c | ||
|
|
ca4cb1790a | ||
|
|
4643f70f4a | ||
|
|
6e828b9c2d | ||
|
|
d5d6abeb1e | ||
|
|
b20ddf2603 | ||
|
|
419911b78b | ||
|
|
954bbddf7e | ||
|
|
b00b6637d2 | ||
|
|
7ad9559653 | ||
|
|
278f892fae | ||
|
|
80e92b77b8 | ||
|
|
185f592ce9 | ||
|
|
1379a062ba | ||
|
|
6184f38330 | ||
|
|
9e45193e2c | ||
|
|
e5fd35da32 | ||
|
|
9e51ba6cd4 |
No files matched your search
@@ -6,3 +6,5 @@ dist/
|
||||
config.json
|
||||
cookies/
|
||||
.superpowers/
|
||||
/bin/
|
||||
scripts/navbar_bigger.png
|
||||
@@ -1,11 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/TeamSpeak_3-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
||||
<img src="https://img.shields.io/badge/TeamSpeak-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TSMusicBot</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>TeamSpeak 3 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,14 +16,15 @@
|
||||
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
|
||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源
|
||||
- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器
|
||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||
@@ -33,6 +34,7 @@
|
||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||
- **播放历史** — 自动记录所有播放过的歌曲
|
||||
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
||||
@@ -40,7 +42,8 @@
|
||||
|
||||
## 截图
|
||||
|
||||
> *截图即将添加*
|
||||
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
|
||||
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -64,8 +67,8 @@ FFmpeg **已自动内置**,无需手动安装。
|
||||
|
||||
```bash
|
||||
# 下载项目
|
||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||
cd tsmusicbot
|
||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||
cd teamspeak-music-bot
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
@@ -85,8 +88,8 @@ npm start
|
||||
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||
cd tsmusicbot/scripts/docker
|
||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||
cd teamspeak-music-bot/scripts/docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
@@ -126,6 +129,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
|
||||
> # 对每个需要迁移的机器人执行(替换 <bot-id> 为实际 UUID):
|
||||
> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
|
||||
> db.execute(\"UPDATE bot_instances SET identity=NULL WHERE id='<bot-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 +215,7 @@ npm run build
|
||||
sudo systemctl start tsmusicbot
|
||||
```
|
||||
|
||||
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
|
||||
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
|
||||
|
||||
## 使用说明
|
||||
|
||||
@@ -217,6 +246,7 @@ sudo systemctl start tsmusicbot
|
||||
| `!play <歌名>` | 搜索并播放 |
|
||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||
| `!add <歌名>` | 添加到播放队列 |
|
||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||
@@ -251,7 +281,7 @@ sudo systemctl start tsmusicbot
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
tsmusicbot/
|
||||
teamspeak-music-bot/
|
||||
├── src/ # 后端源码 (TypeScript)
|
||||
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
||||
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
||||
@@ -260,7 +290,8 @@ tsmusicbot/
|
||||
│ ├── bot/ # 机器人核心
|
||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||
│ │ └── manager.ts # 多实例生命周期管理
|
||||
│ │ ├── manager.ts # 多实例生命周期管理
|
||||
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── config.ts # JSON 配置文件
|
||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
||||
@@ -269,10 +300,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 +345,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 +354,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 +425,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 +456,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 +470,82 @@ 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)。这里只列出重要变更和面向用户的破坏性改动。
|
||||
|
||||
### 最新版本
|
||||
|
||||
**机器人形象自动更新(Bot Profile)**
|
||||
|
||||
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
||||
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
|
||||
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
|
||||
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
|
||||
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
||||
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
|
||||
|
||||
**协议层 & 稳定性**
|
||||
|
||||
- **升级 `@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) | 提供插件开发参考和懒加载设计参考 |
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Bot Profile Manager — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
|
||||
|
||||
## Features
|
||||
|
||||
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|
||||
|---|---------|---------------|-----------------|---------------|---------------|
|
||||
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
|
||||
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
|
||||
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
resolveAndPlay() success / stop / clear / playNext exhausted
|
||||
↓
|
||||
BotInstance → BotProfileManager.onSongChange(song | null)
|
||||
├─ updateAvatar(coverUrl | null) [fire-and-forget]
|
||||
├─ updateDescription(song | null)
|
||||
├─ updateNickname(song | null)
|
||||
├─ updateAwayStatus(song | null)
|
||||
├─ updateChannelDescription(song | null)
|
||||
└─ sendNowPlayingMessage(song) [only when song != null]
|
||||
|
||||
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
|
||||
BotInstance.disconnect() → (no action needed, server cleans up)
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
|
||||
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
|
||||
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
|
||||
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
|
||||
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
|
||||
|
||||
## TS3Client Layer Extensions
|
||||
|
||||
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
|
||||
|
||||
```typescript
|
||||
execCommand(cmd: string): Promise<void>
|
||||
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
|
||||
fileTransferInitUpload(channelID: bigint, path: string, password: string,
|
||||
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
|
||||
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
|
||||
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
|
||||
```
|
||||
|
||||
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
|
||||
|
||||
## BotProfileManager Detail
|
||||
|
||||
```typescript
|
||||
interface ProfileConfig {
|
||||
avatarEnabled: boolean; // default true
|
||||
descriptionEnabled: boolean; // default true
|
||||
nicknameEnabled: boolean; // default true
|
||||
awayStatusEnabled: boolean; // default true
|
||||
channelDescEnabled: boolean; // default true
|
||||
nowPlayingMsgEnabled: boolean; // default true
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Handling
|
||||
|
||||
- Each feature has an independent `permDenied: boolean` flag.
|
||||
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
|
||||
- On `onConnect()` → reset all flags (new connection may have different permissions).
|
||||
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
|
||||
|
||||
### Nickname Truncation
|
||||
|
||||
- Format: `♪ {songInfo} | {defaultNickname}`
|
||||
- TS3 max nickname: 30 characters
|
||||
- If total > 30: truncate songInfo, keep defaultNickname
|
||||
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
|
||||
|
||||
### Avatar Upload Flow (TS3)
|
||||
|
||||
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
|
||||
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
|
||||
3. `uploadFileData(host, info, Readable.from(buffer))`
|
||||
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
|
||||
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
|
||||
|
||||
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
|
||||
|
||||
### Fire-and-Forget
|
||||
|
||||
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
|
||||
|
||||
## Database Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
|
||||
```
|
||||
|
||||
## Web API
|
||||
|
||||
```
|
||||
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
|
||||
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- All profile operations are async, never block playback
|
||||
- Uses existing `axios` dependency for image download
|
||||
- MD5 via Node.js built-in `crypto`
|
||||
- No new npm dependencies required
|
||||
Generated
+347
-349
File diff suppressed because it is too large.
Load diff
+4
-3
@@ -15,9 +15,9 @@
|
||||
"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",
|
||||
"@sansenjian/qq-music-api": "^2.2.10",
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -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",
|
||||
|
||||
+10
-13
@@ -25,30 +25,27 @@ RUN cd web && npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Install production dependencies (with native addons compiled) in the builder
|
||||
# so we don't need build tools in the production image.
|
||||
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
||||
|
||||
# --- Stage 2: Production image ---
|
||||
FROM node:20-slim
|
||||
|
||||
# Install only runtime native build tools (needed for native module rebuild)
|
||||
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
||||
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
||||
# System-installed FFmpeg is always compatible with the container runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ && \
|
||||
ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built output
|
||||
# Copy built output and pre-compiled production node_modules from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/web/dist ./web/dist
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Install production dependencies (includes ffmpeg-static, opus, sqlite3)
|
||||
# These need to compile native addons inside the container
|
||||
RUN npm ci --production && npm cache clean --force
|
||||
|
||||
# Remove build tools to reduce image size
|
||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# FFmpeg is bundled via ffmpeg-static — no system ffmpeg needed
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Data directory for database, cookies, logs
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# TSMusicBot — Docker Compose
|
||||
# 一键部署:docker-compose up -d
|
||||
#
|
||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
|
||||
# 无需安装任何额外软件
|
||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
|
||||
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
|
||||
|
||||
services:
|
||||
tsmusicbot:
|
||||
|
||||
+41
-17
@@ -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 <<EOL
|
||||
[Unit]
|
||||
Description=TSMusicBot - TeamSpeak Music Bot
|
||||
@@ -88,4 +111,5 @@ echo "║ Commands: ║"
|
||||
echo "║ systemctl status tsmusicbot ║"
|
||||
echo "║ systemctl restart tsmusicbot ║"
|
||||
echo "║ systemctl stop tsmusicbot ║"
|
||||
echo "║ journalctl -u tsmusicbot -f ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Open a real Chromium browser at y.qq.com, let the user log in via any
|
||||
method (password / QR / QQ connect), then extract the resulting cookie
|
||||
set and save it to data/cookies/qq.json. Also tests whether the cookie
|
||||
actually unlocks a known VIP track (Jay Chou 稻香) against the local
|
||||
QQ Music API before declaring success.
|
||||
|
||||
Usage:
|
||||
"C:/Users/saopig1/miniforge3/python.exe" scripts/qq_browser_login.py
|
||||
|
||||
Steps:
|
||||
1. A visible Chromium window opens at y.qq.com/n/ryqq/player
|
||||
2. Click the login button in the top right and log in with your
|
||||
real QQ Music account (the one that has VIP)
|
||||
3. The script POLLS cookies in the background and auto-detects
|
||||
successful login by watching for the `uin` cookie to appear
|
||||
4. Once detected, cookies are captured, tested against
|
||||
/getMusicPlay for 稻香, and saved on success
|
||||
5. If VIP still fails, cookies are NOT saved — your existing bot
|
||||
cookie stays untouched
|
||||
|
||||
No terminal input required — the script exits on its own when login
|
||||
is detected (or after the configured timeout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BOT_ROOT = Path(r"C:\Users\saopig1\Music\teamspeak music bot")
|
||||
COOKIE_FILE = BOT_ROOT / "data" / "cookies" / "qq.json"
|
||||
QQ_API = "http://localhost:3200"
|
||||
VIP_TEST_SONGMID = "003aAYrm3GE0Ac" # 稻香 周杰伦
|
||||
|
||||
# How long to wait for the user to finish logging in
|
||||
LOGIN_TIMEOUT_S = 300 # 5 minutes
|
||||
POLL_INTERVAL_S = 1.0
|
||||
# After detecting login, wait a bit for extra cookies (e.g. qqmusic_key)
|
||||
SETTLE_DELAY_S = 4.0
|
||||
|
||||
|
||||
def qq_cookies(ctx) -> list[dict]:
|
||||
wanted_suffixes = (".qq.com", "y.qq.com", ".music.qq.com")
|
||||
return [
|
||||
c for c in ctx.cookies()
|
||||
if any(c.get("domain", "").endswith(s) or c.get("domain", "") == s.lstrip(".")
|
||||
for s in wanted_suffixes)
|
||||
]
|
||||
|
||||
|
||||
def cookies_to_header(cookies: list[dict]) -> str:
|
||||
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
||||
|
||||
|
||||
def cookie_has_uin(cookies: list[dict]) -> str | None:
|
||||
for c in cookies:
|
||||
if c["name"] == "uin" and c["value"]:
|
||||
return c["value"]
|
||||
return None
|
||||
|
||||
|
||||
def test_vip_unlock(cookie_header: str) -> tuple[bool, dict]:
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{QQ_API}/getMusicPlay",
|
||||
params={"songmid": VIP_TEST_SONGMID, "cookie": cookie_header},
|
||||
timeout=10,
|
||||
proxies={"http": None, "https": None},
|
||||
)
|
||||
body = r.json()
|
||||
play = body.get("data", {}).get("playUrl", {}).get(VIP_TEST_SONGMID, {})
|
||||
url = play.get("url", "")
|
||||
return (
|
||||
bool(url),
|
||||
{
|
||||
"url_length": len(url),
|
||||
"url_prefix": url[:120] if url else "",
|
||||
"error": play.get("error", ""),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return False, {"error": f"request failed: {e}"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[setup] launching visible Chromium — look for the window on your desktop")
|
||||
print("[setup] goto https://y.qq.com/n/ryqq/player")
|
||||
print()
|
||||
print("action required:")
|
||||
print(" 1. Click the 登录 button (top-right) in the browser window")
|
||||
print(" 2. Log in with your VIP QQ Music account (QR / password / WeChat)")
|
||||
print(" 3. Do NOTHING in this terminal — the script detects login itself")
|
||||
print()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1280, "height": 820},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/132.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
page.goto("https://y.qq.com/n/ryqq/player", wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as e:
|
||||
print(f"[warn] initial navigation slow: {e}")
|
||||
|
||||
print(f"[wait] polling every {POLL_INTERVAL_S}s for login (timeout {LOGIN_TIMEOUT_S}s)")
|
||||
deadline = time.time() + LOGIN_TIMEOUT_S
|
||||
uin_detected: str | None = None
|
||||
last_report = 0.0
|
||||
while time.time() < deadline:
|
||||
cks = qq_cookies(ctx)
|
||||
uin = cookie_has_uin(cks)
|
||||
if uin:
|
||||
uin_detected = uin
|
||||
print(f"[detect] uin cookie appeared: {uin}")
|
||||
break
|
||||
now = time.time()
|
||||
if now - last_report >= 15:
|
||||
remaining = int(deadline - now)
|
||||
n = len(cks)
|
||||
print(f"[wait] still waiting... {n} qq.com cookies so far, {remaining}s left")
|
||||
last_report = now
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
|
||||
if not uin_detected:
|
||||
print("[abort] login not detected within timeout")
|
||||
browser.close()
|
||||
return 1
|
||||
|
||||
print(f"[settle] waiting {SETTLE_DELAY_S}s for session cookies to populate")
|
||||
time.sleep(SETTLE_DELAY_S)
|
||||
|
||||
cks = qq_cookies(ctx)
|
||||
cookie_header = cookies_to_header(cks)
|
||||
print(f"[capture] {len(cks)} cookies, {len(cookie_header)} char header")
|
||||
|
||||
qm_key = next((c["value"] for c in cks if c["name"] == "qqmusic_key"), "")
|
||||
qm_keyst = next((c["value"] for c in cks if c["name"] == "qm_keyst"), "")
|
||||
p_skey = next((c["value"] for c in cks if c["name"] == "p_skey"), "")
|
||||
print(f"[capture] qqmusic_key: {'present (' + qm_key[:20] + '...)' if qm_key else '(absent)'}")
|
||||
print(f"[capture] qm_keyst : {'present (' + qm_keyst[:20] + '...)' if qm_keyst else '(absent)'}")
|
||||
print(f"[capture] p_skey : {'present' if p_skey else '(absent)'}")
|
||||
|
||||
print("\n[test] calling /getMusicPlay for 稻香 with captured cookie...")
|
||||
unlocked, details = test_vip_unlock(cookie_header)
|
||||
print(f"[test] unlocked: {unlocked}")
|
||||
print(f"[test] details: {details}")
|
||||
|
||||
if not unlocked:
|
||||
print(
|
||||
"\n[result] VIP did NOT unlock even with browser-extracted cookies.\n"
|
||||
" Existing cookie file is UNTOUCHED.\n"
|
||||
" Diagnosis: the login flow is not the bottleneck — the\n"
|
||||
" account likely lacks entitlement for this specific track,\n"
|
||||
" OR QQ requires additional session setup (gateway handshake)\n"
|
||||
" beyond what's in the cookie itself.\n"
|
||||
)
|
||||
# Dump the full cookie set for inspection
|
||||
dump_path = BOT_ROOT / "data" / "cookies" / "qq.browser-capture.json"
|
||||
dump_path.write_text(
|
||||
json.dumps(
|
||||
{"cookie": cookie_header, "cookieList": cks, "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"[dump] full browser cookies written to {dump_path}")
|
||||
print(" (for side-by-side comparison with OAuth-derived cookies)")
|
||||
browser.close()
|
||||
return 2
|
||||
|
||||
print("\n[save] VIP unlocked. Writing cookie to bot...")
|
||||
COOKIE_FILE.write_text(
|
||||
json.dumps(
|
||||
{"cookie": cookie_header, "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"[save] wrote {COOKIE_FILE}")
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
"http://localhost:3000/api/auth/cookie",
|
||||
json={"platform": "qq", "cookie": cookie_header},
|
||||
timeout=5,
|
||||
proxies={"http": None, "https": None},
|
||||
)
|
||||
print(f"[notify] /api/auth/cookie POST: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"[notify] failed to push cookie to bot: {e}")
|
||||
print(" Restart the bot to pick up the new cookie from disk.")
|
||||
|
||||
print("\n[done] VIP should now work through the bot. Try playing 稻香!")
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Open a visible browser at y.qq.com so the user can manually verify
|
||||
whether their VIP account can play 稻香 (Jay Chou) in the real QQ Music
|
||||
web player.
|
||||
|
||||
If the browser plays the song → entitlement exists and our 104003 is a
|
||||
request-signing issue.
|
||||
If the browser refuses / shows a VIP modal / silently fails → the
|
||||
account doesn't have entitlement OR QQ's web player hits the same wall.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Force line-buffered stdout so logs actually reach the output file
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
|
||||
START_URL = "https://y.qq.com/n/ryqq/player"
|
||||
SONG_URL = "https://y.qq.com/n/ryqq/songDetail/003aAYrm3GE0Ac"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
log("[setup] launching visible Chromium")
|
||||
log(f"[setup] start URL: {START_URL}")
|
||||
log(f"[setup] song URL: {SONG_URL}")
|
||||
log("")
|
||||
log("action required:")
|
||||
log(" 1. The browser opens at the player page")
|
||||
log(" 2. Make sure your VIP account is logged in (top-right avatar)")
|
||||
log(" — if not, log in now, the script will wait")
|
||||
log(" 3. Once logged in, the browser will auto-navigate to 稻香")
|
||||
log(" 4. Click the PLAY button and report what happens:")
|
||||
log(" (a) song plays → account has entitlement, issue is request signing")
|
||||
log(" (b) VIP modal → account needs higher tier / digital album purchase")
|
||||
log(" (c) silent failure → QQ web player has same 104003 wall")
|
||||
log("")
|
||||
log("[wait] browser stays open for 5 minutes")
|
||||
log("")
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
except Exception as e:
|
||||
log(f"[fatal] failed to launch Chromium: {e}")
|
||||
return 1
|
||||
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1400, "height": 900},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/132.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
# Log every navigation so we can see if pages fail
|
||||
page.on("framenavigated", lambda f: log(f"[nav] {f.url[:120]}") if f == page.main_frame else None)
|
||||
page.on("pageerror", lambda e: log(f"[js-error] {str(e)[:200]}"))
|
||||
|
||||
# Step 1: open the player page (known working)
|
||||
log(f"[goto] {START_URL}")
|
||||
try:
|
||||
page.goto(START_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
log(f"[ok] loaded: {page.url}")
|
||||
except Exception as e:
|
||||
log(f"[warn] initial goto failed: {e}")
|
||||
log("[warn] browser stays open, try manual navigation")
|
||||
|
||||
# Wait briefly for auth state to settle
|
||||
time.sleep(3)
|
||||
|
||||
# Check if the user is logged in via the uin cookie
|
||||
cookies = ctx.cookies()
|
||||
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
|
||||
if uin:
|
||||
log(f"[auth] logged in as uin={uin}")
|
||||
else:
|
||||
log("[auth] not logged in yet — please log in via the top-right avatar")
|
||||
log("[auth] waiting up to 2 minutes for login...")
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
time.sleep(1)
|
||||
cookies = ctx.cookies()
|
||||
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
|
||||
if uin:
|
||||
log(f"[auth] detected login: uin={uin}")
|
||||
break
|
||||
if not uin:
|
||||
log("[abort] no login detected within 2 minutes")
|
||||
time.sleep(30) # keep browser visible
|
||||
browser.close()
|
||||
return 2
|
||||
|
||||
# Step 2: navigate to the song page
|
||||
log(f"[goto] {SONG_URL}")
|
||||
try:
|
||||
page.goto(SONG_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
log(f"[ok] loaded: {page.url}")
|
||||
except Exception as e:
|
||||
log(f"[warn] song navigation failed: {e}")
|
||||
log(f"[info] current page: {page.url}")
|
||||
|
||||
log("")
|
||||
log("===========================================================")
|
||||
log("browser is open on the song page — click PLAY and observe.")
|
||||
log("keeping browser open for 5 more minutes")
|
||||
log("===========================================================")
|
||||
|
||||
time.sleep(300)
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto("http://localhost:3000")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png")
|
||||
rect = page.locator(".bot-selector-btn").bounding_box()
|
||||
print("bot-selector-btn bbox:", rect)
|
||||
browser.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row.
|
||||
|
||||
Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and
|
||||
asserts the row disappears without any page reload. Does not touch any
|
||||
existing user bot.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
EPHEMERAL_NAME = "rmbot_test"
|
||||
EPHEMERAL_NICK = "RmBotTest"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.text else None
|
||||
|
||||
|
||||
def cleanup():
|
||||
for b in api("/api/bot/")["bots"]:
|
||||
if b["name"] == EPHEMERAL_NAME:
|
||||
try:
|
||||
api(f"/api/bot/{b['id']}", method="DELETE")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
cleanup()
|
||||
new = api(
|
||||
"/api/bot/",
|
||||
method="POST",
|
||||
json={
|
||||
"name": EPHEMERAL_NAME,
|
||||
"serverAddress": "127.0.0.1",
|
||||
"serverPort": 9987,
|
||||
"nickname": EPHEMERAL_NICK,
|
||||
"autoStart": False,
|
||||
},
|
||||
)
|
||||
bot_id = new["id"]
|
||||
print(f"[setup] created ephemeral bot {bot_id[:8]}")
|
||||
|
||||
try:
|
||||
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.8)
|
||||
|
||||
# Open dropdown and confirm the new bot row is present
|
||||
page.locator(".bot-selector-btn").click()
|
||||
page.wait_for_selector(".bot-dropdown")
|
||||
rows_before = page.locator(".bot-dropdown-row").count()
|
||||
print(f"[ui] dropdown rows before remove: {rows_before}")
|
||||
|
||||
# Match the ephemeral row by its name text
|
||||
present = (
|
||||
page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count()
|
||||
)
|
||||
assert present == 1, f"ephemeral row not found (got {present})"
|
||||
|
||||
# Delete via API
|
||||
api(f"/api/bot/{bot_id}", method="DELETE")
|
||||
print("[api] deleted bot")
|
||||
|
||||
# Wait up to 4s for UI to drop the row
|
||||
removed = False
|
||||
for _ in range(40):
|
||||
if (
|
||||
page.locator(
|
||||
".bot-dropdown-row", has_text=EPHEMERAL_NAME
|
||||
).count()
|
||||
== 0
|
||||
):
|
||||
removed = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
rows_after = page.locator(".bot-dropdown-row").count()
|
||||
print(f"[ui] dropdown rows after remove: {rows_after}")
|
||||
assert removed, "ephemeral row did not disappear from UI after DELETE"
|
||||
assert rows_after == rows_before - 1, (
|
||||
f"row count mismatch: before={rows_before} after={rows_after}"
|
||||
)
|
||||
print("[PASS] bot removal propagates to UI via WS")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Corner case regressions that go beyond Bugs A/B/C.
|
||||
|
||||
A. Race — disconnect() called during connect()'s awaited handshake must
|
||||
NOT leave the bot reporting connected=true afterwards.
|
||||
|
||||
B. Config-only commands (vol, mode, clear) must work even when the bot is
|
||||
disconnected (UI should stay usable while the bot is offline).
|
||||
|
||||
C. After a disconnect mid-playback, the player must not be able to
|
||||
auto-advance to the next queued song (trackEnd → resolveAndPlay).
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
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.1)
|
||||
return False
|
||||
|
||||
|
||||
def test_config_commands_when_disconnected(bot_id):
|
||||
"""B. vol/mode/clear should succeed while bot is disconnected."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
|
||||
# volume is the simplest config-only command
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json={"volume": 60},
|
||||
)
|
||||
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json={"mode": "seq"},
|
||||
)
|
||||
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
print("[PASS] config commands (vol/mode/clear) work when disconnected")
|
||||
|
||||
|
||||
def test_play_rejected_when_disconnected(bot_id):
|
||||
"""B (negative). play/add/next/prev should still be rejected."""
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+25
-7
@@ -35,28 +35,39 @@ function ffmpegWorks(bin: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
|
||||
/**
|
||||
* Resolved once at module load.
|
||||
*
|
||||
* Priority: system FFmpeg → bundled ffmpeg-static.
|
||||
*
|
||||
* System-installed FFmpeg is always compatible with the running OS/container,
|
||||
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
|
||||
* (passes `ffmpeg -version` but crashes during actual audio processing due to
|
||||
* incompatible glibc or missing shared libraries).
|
||||
*/
|
||||
const resolvedFfmpeg: string = (() => {
|
||||
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
|
||||
if (ffmpegWorks("ffmpeg")) {
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
// 2. Fall back to bundled ffmpeg-static binary
|
||||
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
|
||||
// it may return a Windows .exe path if node_modules was copied cross-platform.
|
||||
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
|
||||
const onWindows = process.platform === "win32";
|
||||
|
||||
// Only try bundled binary if platform matches
|
||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
||||
return ffmpegPath;
|
||||
}
|
||||
}
|
||||
// Fall back to system ffmpeg
|
||||
if (ffmpegWorks("ffmpeg")) {
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
||||
return "ffmpeg";
|
||||
})();
|
||||
|
||||
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
|
||||
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
||||
function getFfmpegCommand(): string {
|
||||
return resolvedFfmpeg;
|
||||
}
|
||||
@@ -302,6 +313,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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
@@ -107,6 +150,101 @@ describe("PlayQueue", () => {
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("random mode with single song returns null on next", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode plays each song exactly once then stops", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play();
|
||||
const played = new Set<string>();
|
||||
played.add(queue.current()!.id);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const song = queue.next();
|
||||
if (!song) break;
|
||||
played.add(song.id);
|
||||
}
|
||||
// All 3 songs should have been played
|
||||
expect(played).toEqual(new Set(["A", "B", "C"]));
|
||||
// next() after all played should return null
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: removing currently-playing song does not skip others", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.add(makeSong("D"));
|
||||
queue.play(); // plays A (index 0)
|
||||
const second = queue.next()!; // plays some song
|
||||
// Remove the currently-playing song
|
||||
const curIdx = queue.getCurrentIndex();
|
||||
queue.remove(curIdx);
|
||||
// Remaining songs (excluding A and the removed song) should all be reachable
|
||||
const played = new Set<string>();
|
||||
played.add("A"); // already played via play()
|
||||
played.add(second.id); // played and then removed
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
played.add(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// All 4 original songs should have been played or accounted for
|
||||
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
|
||||
});
|
||||
|
||||
it("random mode: prev does not cause duplicate plays", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B or C
|
||||
queue.prev(); // go back — this song is now marked as played
|
||||
// Exhaust remaining songs
|
||||
const ids: string[] = [];
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
ids.push(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// No song ID should appear more than once across the entire session
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("random mode: adding song mid-playback includes the new song", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B
|
||||
// Add a new song while all existing songs have been played
|
||||
queue.add(makeSong("C"));
|
||||
const song = queue.next();
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("C");
|
||||
// After C, should stop
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: setMode preserves current song as played", () => {
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A in sequential mode
|
||||
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
|
||||
// next() should only return B, never A again
|
||||
const song = queue.next();
|
||||
expect(song?.id).toBe("B");
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random-loop mode never returns null", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("1"));
|
||||
|
||||
+29
-9
@@ -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
|
||||
@@ -20,6 +20,7 @@ export class PlayQueue {
|
||||
private songs: QueuedSong[] = [];
|
||||
private currentIndex = -1;
|
||||
private mode: PlayMode = PlayMode.Sequential;
|
||||
private playedIndices = new Set<number>();
|
||||
|
||||
add(song: QueuedSong): void {
|
||||
this.songs.push(song);
|
||||
@@ -36,28 +37,39 @@ 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;
|
||||
}
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
// Rebuild playedIndices to account for shifted indices
|
||||
const newPlayed = new Set<number>();
|
||||
for (const idx of this.playedIndices) {
|
||||
if (idx === index) continue;
|
||||
newPlayed.add(idx > index ? idx - 1 : idx);
|
||||
}
|
||||
this.playedIndices = newPlayed;
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.songs = [];
|
||||
this.currentIndex = -1;
|
||||
this.playedIndices.clear();
|
||||
}
|
||||
|
||||
play(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
this.playedIndices.clear();
|
||||
this.currentIndex = 0;
|
||||
this.playedIndices.add(0);
|
||||
return this.songs[0];
|
||||
}
|
||||
|
||||
playAt(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
this.playedIndices.clear();
|
||||
this.currentIndex = index;
|
||||
this.playedIndices.add(index);
|
||||
return this.songs[index];
|
||||
}
|
||||
|
||||
@@ -76,12 +88,15 @@ export class PlayQueue {
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
case PlayMode.Random: {
|
||||
if (this.songs.length === 1) return this.songs[0];
|
||||
let nextIndex: number;
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||
const unplayed: number[] = [];
|
||||
for (let i = 0; i < this.songs.length; i++) {
|
||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||
}
|
||||
if (unplayed.length === 0) return null;
|
||||
const nextIndex =
|
||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||
this.currentIndex = nextIndex;
|
||||
this.playedIndices.add(nextIndex);
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.RandomLoop: {
|
||||
@@ -109,6 +124,7 @@ export class PlayQueue {
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
@@ -136,6 +152,10 @@ export class PlayQueue {
|
||||
|
||||
setMode(mode: PlayMode): void {
|
||||
this.mode = mode;
|
||||
this.playedIndices.clear();
|
||||
if (this.currentIndex >= 0) {
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
|
||||
+123
-11
@@ -13,8 +13,9 @@ import {
|
||||
type ParsedCommand,
|
||||
} from "./commands.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
|
||||
export interface BotInstanceOptions {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ export interface BotInstanceOptions {
|
||||
neteaseProvider: MusicProvider;
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -51,12 +53,15 @@ 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<string>();
|
||||
private isAdvancing = false;
|
||||
private profileManager: BotProfileManager;
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -65,6 +70,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 });
|
||||
@@ -73,6 +79,14 @@ export class BotInstance extends EventEmitter {
|
||||
this.player = new AudioPlayer(this.logger);
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
const profileConfig = this.database.getProfileConfig(this.id);
|
||||
this.profileManager = new BotProfileManager(
|
||||
this.tsClient,
|
||||
this.logger,
|
||||
profileConfig,
|
||||
options.tsOptions.nickname,
|
||||
);
|
||||
|
||||
this.setupPlayerEvents();
|
||||
this.setupTsEvents();
|
||||
}
|
||||
@@ -105,23 +119,43 @@ 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<void> {
|
||||
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.profileManager.onConnect();
|
||||
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<void> {
|
||||
@@ -162,6 +196,24 @@ export class BotInstance extends EventEmitter {
|
||||
cmd: ParsedCommand,
|
||||
msg?: TS3TextMessage
|
||||
): Promise<string | null> {
|
||||
// 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 +264,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<string>): 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<boolean> {
|
||||
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 +294,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({
|
||||
@@ -243,6 +317,10 @@ export class BotInstance extends EventEmitter {
|
||||
platform: song.platform,
|
||||
coverUrl: song.coverUrl,
|
||||
});
|
||||
// Update bot presence (fire-and-forget — never blocks playback)
|
||||
this.profileManager.onSongChange(song).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile update failed after song change");
|
||||
});
|
||||
this.emit("stateChange");
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -278,7 +356,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()})`;
|
||||
}
|
||||
@@ -298,6 +389,9 @@ export class BotInstance extends EventEmitter {
|
||||
private cmdStop(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||
});
|
||||
this.emit("stateChange");
|
||||
return "Stopped and queue cleared";
|
||||
}
|
||||
@@ -348,6 +442,9 @@ export class BotInstance extends EventEmitter {
|
||||
private cmdClear(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||
});
|
||||
this.emit("stateChange");
|
||||
return "Queue cleared";
|
||||
}
|
||||
@@ -430,8 +527,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 +572,7 @@ export class BotInstance extends EventEmitter {
|
||||
`${p}play <song> — Search and play`,
|
||||
`${p}play -q <song> — Search from QQ Music`,
|
||||
`${p}play -b <song> — Search from BiliBili`,
|
||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
||||
`${p}add <song> — Add to queue`,
|
||||
`${p}pause/resume — Pause/resume`,
|
||||
`${p}next/prev — Next/previous`,
|
||||
@@ -490,24 +591,31 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async playNext(): Promise<void> {
|
||||
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();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
this.emit("stateChange");
|
||||
} finally {
|
||||
@@ -554,6 +662,10 @@ export class BotInstance extends EventEmitter {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getProfileManager(): BotProfileManager {
|
||||
return this.profileManager;
|
||||
}
|
||||
|
||||
getIdentityExport(): string | undefined {
|
||||
return this.tsClient.getIdentityExport();
|
||||
}
|
||||
|
||||
+117
-6
@@ -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<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, 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<string, BotInstance>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import axios from "axios";
|
||||
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
||||
import type { ProfileConfig } from "../data/database.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
const TS3_NICKNAME_MAX = 30;
|
||||
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
|
||||
const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
*
|
||||
* Every update is permission-safe: if a feature fails due to insufficient
|
||||
* server permissions, it silently disables itself until the next reconnect.
|
||||
*/
|
||||
export class BotProfileManager {
|
||||
private tsClient: TS3Client;
|
||||
private logger: Logger;
|
||||
private config: ProfileConfig;
|
||||
private defaultNickname: string;
|
||||
|
||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||
private permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Monotonically increasing generation counter. Incremented on every
|
||||
* onSongChange / onConnect call. Long-running operations (avatar
|
||||
* download/upload) check this before committing their result — if
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
logger: Logger,
|
||||
config: ProfileConfig,
|
||||
defaultNickname: string,
|
||||
) {
|
||||
this.tsClient = tsClient;
|
||||
this.logger = logger.child({ component: "profile" });
|
||||
this.config = { ...config };
|
||||
this.defaultNickname = defaultNickname;
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/**
|
||||
* Called when a new song starts playing (song != null) or playback
|
||||
* stops (song == null).
|
||||
*
|
||||
* Commands are serialized to avoid overwhelming the TS3 command queue.
|
||||
* Nickname + away status are merged into a single `clientupdate` call.
|
||||
*
|
||||
* A generation counter guards against stale updates: if a newer
|
||||
* onSongChange fires while the avatar is still downloading, the old
|
||||
* update is discarded.
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
|
||||
// 1. Avatar first — file transfer uses its own response tracker and
|
||||
// must run before sendCommandNoWait calls whose orphaned responses
|
||||
// could confuse the command matcher.
|
||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||
if (this.generation !== gen) return; // superseded
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
}
|
||||
|
||||
getConfig(): ProfileConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(partial: Partial<ProfileConfig>): void {
|
||||
Object.assign(this.config, partial);
|
||||
}
|
||||
|
||||
// --- Internal update methods ---
|
||||
|
||||
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
|
||||
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
|
||||
try {
|
||||
if (!coverUrl) {
|
||||
await this.clearAvatar(gen);
|
||||
return;
|
||||
}
|
||||
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
|
||||
const thumbUrl = this.thumbnailUrl(coverUrl);
|
||||
const imageBuffer = await this.downloadImage(thumbUrl);
|
||||
|
||||
// Check generation after the slow download — bail if superseded.
|
||||
if (this.generation !== gen) return;
|
||||
|
||||
if (!imageBuffer || imageBuffer.length === 0) return;
|
||||
if (imageBuffer.length > AVATAR_MAX_BYTES) {
|
||||
this.logger.warn(
|
||||
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
|
||||
"Cover image still too large after resize — skipping avatar update",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap the file-transfer sequence with a timeout — the TS3
|
||||
// full-client file transfer can silently hang.
|
||||
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
||||
this.logger.info("Avatar updated");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
||||
const host = this.tsClient.getHost();
|
||||
const info = await this.tsClient.fileTransferInitUpload(
|
||||
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
||||
);
|
||||
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
||||
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
||||
}
|
||||
|
||||
private async clearAvatar(gen: number): Promise<void> {
|
||||
try {
|
||||
await this.withTimeout(
|
||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||
FILE_TRANSFER_TIMEOUT_MS,
|
||||
);
|
||||
} catch {
|
||||
// File may not exist or transfer timed out — that's fine
|
||||
}
|
||||
// Bail if a newer song started while we were deleting
|
||||
if (this.generation !== gen) return;
|
||||
try {
|
||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
await httpQuery.clientUpdate({ client_description: text });
|
||||
} else {
|
||||
// clientupdate rejects client_description (error 1538).
|
||||
// Use clientedit on our own clid instead — this is what
|
||||
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||
const clid = this.tsClient.getClientId();
|
||||
if (clid <= 0) return;
|
||||
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||
// remaining profile updates (channeledit, now-playing msg).
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.logger.info("Description updated");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send a single `clientupdate` command that sets nickname
|
||||
* and away status together, avoiding multiple round-trips that can
|
||||
* cause command-queue timeouts on the TS3 protocol.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
const parts: string[] = [];
|
||||
|
||||
// --- Nickname ---
|
||||
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
||||
if (!song) {
|
||||
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
|
||||
} else {
|
||||
const nickname = this.buildNickname(song);
|
||||
if (nickname) {
|
||||
parts.push(`client_nickname=${escapeTS3(nickname)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Away status ---
|
||||
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
||||
if (song) {
|
||||
parts.push("client_away=0");
|
||||
} else {
|
||||
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
try {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6: build a properties object
|
||||
const props: Record<string, string | number> = {};
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
|
||||
}
|
||||
await httpQuery.clientUpdate(props);
|
||||
} else {
|
||||
// Use sendCommandNoWait: the TS3 full-client protocol often
|
||||
// doesn't return a timely error response for clientupdate,
|
||||
// causing execCommand to time out after 10s.
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||
}
|
||||
this.logger.info("Client properties updated (nickname + away)");
|
||||
} catch (err) {
|
||||
// Flag both features on permission error
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nickname string that fits within TS3_NICKNAME_MAX.
|
||||
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
|
||||
* not characters.
|
||||
*/
|
||||
private buildNickname(song: QueuedSong): string | null {
|
||||
const songInfo = `${song.name} - ${song.artist}`;
|
||||
const prefix = "\u266A "; // ♪
|
||||
const sep = " - ";
|
||||
const suffix = `${sep}${this.defaultNickname}`;
|
||||
|
||||
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
|
||||
if (overheadBytes >= TS3_NICKNAME_MAX) {
|
||||
// Default nickname alone is too long with decoration — skip
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
|
||||
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
|
||||
return `${prefix}${truncated}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
|
||||
* Appends an ellipsis if truncation occurred, taking its byte cost
|
||||
* into account. Never splits a multi-byte character.
|
||||
*/
|
||||
private truncateUtf8(str: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
|
||||
const ellipsis = "\u2026"; // …
|
||||
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
|
||||
const target = maxBytes - ellipsisBytes;
|
||||
if (target <= 0) return ellipsis;
|
||||
// Walk characters, accumulating byte length
|
||||
let byteLen = 0;
|
||||
let end = 0;
|
||||
for (const ch of str) {
|
||||
const chBytes = Buffer.byteLength(ch, "utf8");
|
||||
if (byteLen + chBytes > target) break;
|
||||
byteLen += chBytes;
|
||||
end += ch.length; // ch.length handles surrogate pairs
|
||||
}
|
||||
return str.slice(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
try {
|
||||
const channelId = this.tsClient.getChannelId();
|
||||
if (channelId === 0n) return; // unknown channel
|
||||
|
||||
if (!song) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||
];
|
||||
const desc = lines.join("\\n");
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
|
||||
await this.tsClient.sendTextMessage(text);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("nowPlayingMsg", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
* BiliBili and YouTube covers fall through to the size-check guard.
|
||||
*/
|
||||
private thumbnailUrl(url: string): string {
|
||||
if (url.includes("music.126.net") || url.includes("netease")) {
|
||||
return url.includes("?") ? url : `${url}?param=200y200`;
|
||||
}
|
||||
if (url.includes("qqmusic") || url.includes("qq.com")) {
|
||||
return url.replace(/\/\d+$/, "/200");
|
||||
}
|
||||
if (url.includes("bilivideo") || url.includes("hdslb")) {
|
||||
// BiliBili CDN supports @<w>w_<h>h suffix
|
||||
return url.includes("@") ? url : `${url}@200w_200h`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private async downloadImage(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const resp = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 8000,
|
||||
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
|
||||
});
|
||||
return Buffer.from(resp.data);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, url }, "Failed to download cover image");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Race a promise against a timeout. */
|
||||
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleFeatureError(
|
||||
feature: keyof typeof this.permDenied,
|
||||
err: unknown,
|
||||
): void {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
// Disable the feature for this session on unrecoverable errors:
|
||||
// - permission / insufficient → server denies the action
|
||||
// - invalid parameter → command not supported by this protocol
|
||||
if (
|
||||
msg.includes("permission") ||
|
||||
msg.includes("insufficient") ||
|
||||
msg.includes("invalid parameter")
|
||||
) {
|
||||
this.permDenied[feature] = true;
|
||||
this.logger.info(
|
||||
{ feature, reason: msg },
|
||||
"Feature disabled for this session (will retry after reconnect)",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn({ feature, err }, "Profile update failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+106
-6
@@ -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,9 +24,33 @@ 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;
|
||||
}
|
||||
|
||||
export interface ProfileConfig {
|
||||
avatarEnabled: boolean;
|
||||
descriptionEnabled: boolean;
|
||||
nicknameEnabled: boolean;
|
||||
awayStatusEnabled: boolean;
|
||||
channelDescEnabled: boolean;
|
||||
nowPlayingMsgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
||||
avatarEnabled: true,
|
||||
descriptionEnabled: true,
|
||||
nicknameEnabled: true,
|
||||
awayStatusEnabled: true,
|
||||
channelDescEnabled: true,
|
||||
nowPlayingMsgEnabled: true,
|
||||
};
|
||||
|
||||
export interface BotDatabase {
|
||||
db: Database.Database;
|
||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||
@@ -34,16 +58,40 @@ export interface BotDatabase {
|
||||
saveBotInstance(instance: BotInstance): void;
|
||||
getBotInstances(): BotInstance[];
|
||||
deleteBotInstance(id: string): boolean;
|
||||
getProfileConfig(botId: string): ProfileConfig;
|
||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
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 ''");
|
||||
}
|
||||
// Profile feature flags
|
||||
const profileCols = [
|
||||
"profile_avatar_enabled",
|
||||
"profile_description_enabled",
|
||||
"profile_nickname_enabled",
|
||||
"profile_away_enabled",
|
||||
"profile_channel_desc_enabled",
|
||||
"profile_now_playing_enabled",
|
||||
];
|
||||
for (const col of profileCols) {
|
||||
if (!names.includes(col)) {
|
||||
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initTables(db: Database.Database): void {
|
||||
@@ -69,6 +117,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 +141,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 +151,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
|
||||
`);
|
||||
|
||||
@@ -107,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
|
||||
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
||||
|
||||
const selectProfileConfig = db.prepare(`
|
||||
SELECT profile_avatar_enabled, profile_description_enabled,
|
||||
profile_nickname_enabled, profile_away_enabled,
|
||||
profile_channel_desc_enabled, profile_now_playing_enabled
|
||||
FROM bot_instances WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateProfileConfig = db.prepare(`
|
||||
UPDATE bot_instances SET
|
||||
profile_avatar_enabled = @avatar,
|
||||
profile_description_enabled = @description,
|
||||
profile_nickname_enabled = @nickname,
|
||||
profile_away_enabled = @away,
|
||||
profile_channel_desc_enabled = @channelDesc,
|
||||
profile_now_playing_enabled = @nowPlaying
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
return {
|
||||
db,
|
||||
|
||||
@@ -133,6 +205,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,
|
||||
}));
|
||||
},
|
||||
@@ -142,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
getProfileConfig(botId) {
|
||||
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
|
||||
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
|
||||
return {
|
||||
avatarEnabled: row.profile_avatar_enabled === 1,
|
||||
descriptionEnabled: row.profile_description_enabled === 1,
|
||||
nicknameEnabled: row.profile_nickname_enabled === 1,
|
||||
awayStatusEnabled: row.profile_away_enabled === 1,
|
||||
channelDescEnabled: row.profile_channel_desc_enabled === 1,
|
||||
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
|
||||
};
|
||||
},
|
||||
|
||||
saveProfileConfig(botId, config) {
|
||||
updateProfileConfig.run({
|
||||
id: botId,
|
||||
avatar: config.avatarEnabled ? 1 : 0,
|
||||
description: config.descriptionEnabled ? 1 : 0,
|
||||
nickname: config.nicknameEnabled ? 1 : 0,
|
||||
away: config.awayStatusEnabled ? 1 : 0,
|
||||
channelDesc: config.channelDescEnabled ? 1 : 0,
|
||||
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
|
||||
+28
-7
@@ -17,7 +17,9 @@ export interface ApiServerManager {
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
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));
|
||||
});
|
||||
@@ -30,6 +32,7 @@ export function createApiServerManager(
|
||||
logger: Logger
|
||||
): ApiServerManager {
|
||||
let neteaseServer: Server | null = null;
|
||||
let qqMusicServer: Server | null = null;
|
||||
|
||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||
@@ -60,7 +63,10 @@ export function createApiServerManager(
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
|
||||
// Start QQ Music API (auto-starts on import)
|
||||
// Start QQ Music API. Older versions auto-started on import; the
|
||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||
// so we explicitly call .listen() on the imported Koa app and keep
|
||||
// the server handle for clean shutdown.
|
||||
try {
|
||||
const portFree = await isPortFree(options.qqMusicPort);
|
||||
if (!portFree) {
|
||||
@@ -69,11 +75,22 @@ export function createApiServerManager(
|
||||
"QQ Music API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
await import("@sansenjian/qq-music-api");
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||
const koaApp = qqModule.default ?? qqModule;
|
||||
if (koaApp && typeof koaApp.listen === "function") {
|
||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||
resolve(srv)
|
||||
);
|
||||
srv.on("error", reject);
|
||||
});
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
} else {
|
||||
logger.warn("QQ Music API module does not expose a Koa app");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -89,6 +106,10 @@ export function createApiServerManager(
|
||||
(neteaseServer as any).close();
|
||||
}
|
||||
neteaseServer = null;
|
||||
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
|
||||
(qqMusicServer as any).close();
|
||||
}
|
||||
qqMusicServer = null;
|
||||
},
|
||||
|
||||
getNeteaseBaseUrl(): string {
|
||||
|
||||
@@ -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<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
||||
|
||||
+77
-26
@@ -66,7 +66,12 @@ export class QQMusicProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
// getSongInfo requires cookie; use search as fallback
|
||||
// Try /getSongInfo for full metadata, but fall through to a minimal
|
||||
// stub if the library endpoint fails (current @sansenjian/qq-music-api
|
||||
// returns upstream code 500001 for this route — the param format it
|
||||
// sends doesn't match QQ's current API). The bot's resolveAndPlay path
|
||||
// only needs `id` and `platform` to fetch a play URL, and the fallback
|
||||
// stub is sufficient to let /play-by-id and /add-by-id flows succeed.
|
||||
try {
|
||||
const res = await this.api.get("/getSongInfo", {
|
||||
params: { songmid: songId, ...this.cookieParams },
|
||||
@@ -87,9 +92,20 @@ export class QQMusicProvider implements MusicProvider {
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fallback: search by songmid (less reliable)
|
||||
// fall through to stub
|
||||
}
|
||||
return null;
|
||||
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
|
||||
// play URL. Name/artist/album will be empty in play history, but the
|
||||
// song will actually play, which is the important part.
|
||||
return {
|
||||
id: songId,
|
||||
name: "",
|
||||
artist: "",
|
||||
album: "",
|
||||
duration: 0,
|
||||
coverUrl: "",
|
||||
platform: "qq",
|
||||
};
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
@@ -152,30 +168,53 @@ export class QQMusicProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
// @sansenjian/qq-music-api 2.x returns { img, qrsig, ptqrtoken } via
|
||||
// customResponse (no { response: ... } wrapping). /checkQQLoginQr
|
||||
// requires BOTH qrsig AND ptqrtoken — passing only one gives a 400
|
||||
// "参数错误". Pack both into the opaque `key` field so the polling
|
||||
// endpoint can split them back out. Separator "|" is safe: QQ tokens
|
||||
// are alphanumeric.
|
||||
const res = await this.api.get("/getQQLoginQr");
|
||||
const qrsig: string = res.data?.qrsig ?? "";
|
||||
const ptqrtoken: string = String(res.data?.ptqrtoken ?? "");
|
||||
return {
|
||||
qrUrl: "",
|
||||
qrImg: res.data?.img ?? "",
|
||||
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
|
||||
key: `${qrsig}|${ptqrtoken}`,
|
||||
};
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(
|
||||
key: string
|
||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
const res = await this.api.get("/checkQQLoginQr", {
|
||||
params: { qrsig: key },
|
||||
});
|
||||
const code = res.data?.code ?? res.data?.response?.code;
|
||||
if (code === 0) {
|
||||
if (res.data?.cookie) {
|
||||
this.cookie = res.data.cookie;
|
||||
}
|
||||
const [qrsig, ptqrtoken] = key.split("|");
|
||||
if (!qrsig || !ptqrtoken) return "expired";
|
||||
|
||||
// NOTE: /checkQQLoginQr is registered as POST only in
|
||||
// @sansenjian/qq-music-api 2.x. GET returns 405 Method Not Allowed.
|
||||
let res;
|
||||
try {
|
||||
res = await this.api.post("/checkQQLoginQr", null, {
|
||||
params: { qrsig, ptqrtoken },
|
||||
});
|
||||
} catch {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
// customResponse shape:
|
||||
// success: { isOk: true, message: '登录成功', session: { cookie, ... } }
|
||||
// scanning: { isOk: false, refresh: false, message: '未扫描二维码' }
|
||||
// expired: { isOk: false, refresh: true, message: '二维码已失效' }
|
||||
const body = res.data;
|
||||
if (body?.isOk === true) {
|
||||
const cookie: string = body.session?.cookie ?? "";
|
||||
if (cookie) this.cookie = cookie;
|
||||
return "confirmed";
|
||||
}
|
||||
if (code === 1) return "scanned";
|
||||
if (code === 2) return "waiting";
|
||||
return "expired";
|
||||
if (body?.refresh === true) return "expired";
|
||||
if (typeof body?.message === "string" && body.message.includes("未扫描"))
|
||||
return "waiting";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
setCookie(cookie: string): void {
|
||||
@@ -188,20 +227,32 @@ export class QQMusicProvider implements MusicProvider {
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
if (!this.cookie) return { loggedIn: false };
|
||||
// /getUserAvatar in @sansenjian/qq-music-api 2.x is NOT registered on
|
||||
// the main router; the real endpoint is /user/getUserAvatar, and even
|
||||
// that just builds a static URL from a uin without validating the
|
||||
// cookie against QQ. Round-trip through /user/getUserPlaylists which
|
||||
// actually hits QQ Music with the cookie; if the upstream returns
|
||||
// code=0, the cookie is valid.
|
||||
//
|
||||
// IMPORTANT: /user/getUserPlaylists requires `uin` as a query param —
|
||||
// the library 400s with "缺少 uin 参数" otherwise. Parse it out of the
|
||||
// cookie (uin=<qq>; comes after the various *uin prefixed names, which
|
||||
// is why the regex anchors on a word boundary).
|
||||
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
||||
const uin = uinMatch ? uinMatch[1] : "";
|
||||
if (!uin) return { loggedIn: false };
|
||||
try {
|
||||
const res = await this.api.get("/getUserAvatar", {
|
||||
params: { ...this.cookieParams },
|
||||
const res = await this.api.get("/user/getUserPlaylists", {
|
||||
params: { uin, ...this.cookieParams },
|
||||
});
|
||||
if (res.data?.response?.data) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
nickname: res.data.response.data.nickname,
|
||||
avatarUrl: res.data.response.data.headpic,
|
||||
};
|
||||
}
|
||||
if (res.data?.response?.code !== 0) return { loggedIn: false };
|
||||
return {
|
||||
loggedIn: true,
|
||||
nickname: `QQ ${uin}`,
|
||||
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100`,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
return { loggedIn: false };
|
||||
}
|
||||
return { loggedIn: false };
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> | null = null;
|
||||
async function checkYtDlpAvailable(): Promise<boolean> {
|
||||
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<string> {
|
||||
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<SearchResult> {
|
||||
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<string | null> {
|
||||
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<Song | null> {
|
||||
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<Song[]> {
|
||||
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<Playlist[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAlbumSongs(_albumId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLyrics(_songId: string): Promise<LyricLine[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
return { qrUrl: "", key: "" };
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(
|
||||
_key: string
|
||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
setCookie(_cookie: string): void {}
|
||||
getCookie(): string { return ""; }
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
// 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)",
|
||||
};
|
||||
}
|
||||
}
|
||||
+169
-7
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
Client as TS3FullClient,
|
||||
generateIdentity as genTS3Identity,
|
||||
@@ -7,23 +8,48 @@ import {
|
||||
listChannels,
|
||||
listClients,
|
||||
clientMove,
|
||||
fileTransferDeleteFile,
|
||||
type Identity,
|
||||
type TextMessage,
|
||||
type ClientInfo,
|
||||
type FileUploadInfo,
|
||||
} 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 type { FileUploadInfo } from "@honeybbq/teamspeak-client";
|
||||
|
||||
/** Escape a string for use in TS3 ServerQuery-style commands. */
|
||||
export function escapeTS3(str: string): string {
|
||||
return str
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/ /g, "\\s")
|
||||
.replace(/\//g, "\\/")
|
||||
.replace(/\|/g, "\\p")
|
||||
.replace(/\t/g, "\\t")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r");
|
||||
}
|
||||
|
||||
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 +66,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<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||
super();
|
||||
@@ -52,6 +81,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<void> {
|
||||
// Clean up any existing connection before creating a new one
|
||||
if (this.client) {
|
||||
@@ -66,23 +105,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<typeof setTimeout> | 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 +221,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) {
|
||||
@@ -188,6 +294,56 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raw command & file transfer pass-through ---
|
||||
|
||||
async execCommand(cmd: string): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.execCommand(cmd);
|
||||
}
|
||||
|
||||
/** Fire a command without waiting for the server's response. */
|
||||
async sendCommandNoWait(cmd: string): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.sendCommandNoWait(cmd);
|
||||
}
|
||||
|
||||
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
return this.client.execCommandWithResponse(cmd);
|
||||
}
|
||||
|
||||
async fileTransferInitUpload(
|
||||
channelID: bigint,
|
||||
path: string,
|
||||
password: string,
|
||||
size: bigint,
|
||||
overwrite = true,
|
||||
): Promise<FileUploadInfo> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
|
||||
}
|
||||
|
||||
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.uploadFileData(host, info, data);
|
||||
}
|
||||
|
||||
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await fileTransferDeleteFile(this.client, channelID, paths);
|
||||
}
|
||||
|
||||
/** The server host (needed for file transfer TCP connections). */
|
||||
getHost(): string {
|
||||
return this.options.host;
|
||||
}
|
||||
|
||||
/** The current channel ID of this client. */
|
||||
getChannelId(): bigint {
|
||||
if (!this.client) return 0n;
|
||||
return this.client.channelID();
|
||||
}
|
||||
|
||||
private voiceFramesSent = 0;
|
||||
|
||||
sendVoiceData(opusFrame: Buffer): void {
|
||||
@@ -225,6 +381,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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HttpQueryOptions>;
|
||||
|
||||
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<string, unknown>,
|
||||
): Promise<HttpQueryResult> {
|
||||
const { host, port, useTls, apiKey, timeoutMs } = this.options;
|
||||
const transport = useTls ? https : http;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
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<boolean> {
|
||||
try {
|
||||
const result = await this.request("GET", "/");
|
||||
return result.status >= 200 && result.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** List virtual servers */
|
||||
async serverList(): Promise<HttpQueryResult> {
|
||||
return this.request("GET", "/1/serverlist");
|
||||
}
|
||||
|
||||
/** List clients on a virtual server */
|
||||
async clientList(sid = 1): Promise<HttpQueryResult> {
|
||||
return this.request("GET", `/1/clientlist?sid=${sid}`);
|
||||
}
|
||||
|
||||
/** List channels on a virtual server */
|
||||
async channelList(sid = 1): Promise<HttpQueryResult> {
|
||||
return this.request("GET", `/1/channellist?sid=${sid}`);
|
||||
}
|
||||
|
||||
/** Send a text message */
|
||||
async sendTextMessage(
|
||||
targetMode: number,
|
||||
target: number,
|
||||
msg: string,
|
||||
sid = 1,
|
||||
): Promise<HttpQueryResult> {
|
||||
return this.request("POST", `/1/sendtextmessage?sid=${sid}`, {
|
||||
targetmode: targetMode,
|
||||
target,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update client properties (e.g., description) */
|
||||
async clientUpdate(
|
||||
properties: Record<string, string | number>,
|
||||
sid = 1,
|
||||
): Promise<HttpQueryResult> {
|
||||
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<HttpQueryResult> {
|
||||
const body: Record<string, unknown> = { clid, cid };
|
||||
if (cpw) body.cpw = cpw;
|
||||
return this.request("POST", `/1/clientmove?sid=${sid}`, body);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProtocolDetectResult> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+87
-26
@@ -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) {
|
||||
@@ -303,6 +343,27 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
// --- Profile config endpoints ---
|
||||
|
||||
router.get("/:botId/profile", (req, res) => {
|
||||
const bot = (req as any).bot;
|
||||
res.json(bot.getProfileManager().getConfig());
|
||||
});
|
||||
|
||||
router.put("/:botId/profile", (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const pm = bot.getProfileManager();
|
||||
pm.updateConfig(req.body);
|
||||
if (database) {
|
||||
database.saveProfileConfig(bot.id, pm.getConfig());
|
||||
}
|
||||
res.json(pm.getConfig());
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:botId/history", (req, res) => {
|
||||
if (!database) {
|
||||
res.json({ history: [] });
|
||||
|
||||
+47
-13
@@ -10,8 +10,9 @@ export function setupWebSocket(
|
||||
): () => void {
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
/** Track which bots already have listeners attached */
|
||||
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
|
||||
const attachedBots = new Map<string, {
|
||||
bot: BotInstance;
|
||||
stateChange: () => 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();
|
||||
};
|
||||
}
|
||||
+139
-30
@@ -9,36 +9,51 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<!-- Multi-bot selector -->
|
||||
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
|
||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
|
||||
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
|
||||
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
|
||||
</button>
|
||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||
<button
|
||||
<div
|
||||
v-for="bot in store.bots"
|
||||
:key="bot.id"
|
||||
class="bot-dropdown-item"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@click="selectBot(bot.id)"
|
||||
class="bot-dropdown-row"
|
||||
>
|
||||
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||||
<span class="bot-dropdown-name">{{ bot.name }}</span>
|
||||
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
|
||||
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
|
||||
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||||
<span v-else class="bot-offline-badge">离线</span>
|
||||
</button>
|
||||
<button
|
||||
class="bot-dropdown-item"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@click="selectBot(bot.id)"
|
||||
>
|
||||
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||||
<span class="bot-dropdown-name">{{ bot.name }}</span>
|
||||
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
|
||||
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
|
||||
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||||
<span v-else class="bot-offline-badge">离线</span>
|
||||
</button>
|
||||
<button
|
||||
class="bot-power-btn"
|
||||
:class="{ online: bot.connected }"
|
||||
:title="bot.connected ? `停止 ${bot.name}` : `启动 ${bot.name}`"
|
||||
:disabled="togglingBots[bot.id]"
|
||||
@click.stop="togglePower(bot)"
|
||||
>
|
||||
<Icon :icon="bot.connected ? 'mdi:power' : 'mdi:power-off'" />
|
||||
</button>
|
||||
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
|
||||
<Icon icon="mdi:link-variant" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="bot-dropdown-divider" />
|
||||
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single bot status (original behavior) -->
|
||||
<div v-else-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
|
||||
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
|
||||
</div>
|
||||
|
||||
<RouterLink to="/settings" class="settings-btn">
|
||||
<Icon icon="mdi:cog" />
|
||||
</RouterLink>
|
||||
@@ -55,12 +70,36 @@ const store = usePlayerStore();
|
||||
const activeBot = computed(() => store.activeBot);
|
||||
const dropdownOpen = ref(false);
|
||||
const selectorRef = ref<HTMLElement | null>(null);
|
||||
const togglingBots = ref<Record<string, boolean>>({});
|
||||
|
||||
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; }
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="redirect-page">
|
||||
<span v-if="notFound">机器人不存在或未加载</span>
|
||||
<span v-else>正在跳转...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = usePlayerStore();
|
||||
const notFound = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const botId = route.params.id as string;
|
||||
if (!store.bots.length) {
|
||||
await store.fetchBots();
|
||||
}
|
||||
const bot = store.bots.find((b) => b.id === botId);
|
||||
if (bot) {
|
||||
store.setActiveBotId(botId);
|
||||
router.replace('/');
|
||||
} else {
|
||||
notFound.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redirect-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -76,6 +76,10 @@
|
||||
<label>频道密码(可选)</label>
|
||||
<input v-model="editForm.channelPassword" class="input" type="password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器密码(可选)</label>
|
||||
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
||||
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
||||
@@ -108,6 +112,10 @@
|
||||
<label>默认频道(可选)</label>
|
||||
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器密码(可选)</label>
|
||||
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||
</div>
|
||||
<button class="btn-primary" @click="createBot">创建</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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<string | null>(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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user