mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8a5dfdf09 |
No files matched your search
@@ -6,5 +6,3 @@ dist/
|
|||||||
config.json
|
config.json
|
||||||
cookies/
|
cookies/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
/bin/
|
|
||||||
scripts/navbar_bigger.png
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/TeamSpeak-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
<img src="https://img.shields.io/badge/TeamSpeak_3-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">TSMusicBot</h1>
|
<h1 align="center">TSMusicBot</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
<strong>TeamSpeak 3 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -16,15 +16,14 @@
|
|||||||
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
|
<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/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/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>
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源
|
||||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器
|
||||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
|
||||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||||
- **播放历史** — 自动记录所有播放过的歌曲
|
- **播放历史** — 自动记录所有播放过的歌曲
|
||||||
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
||||||
@@ -42,8 +40,7 @@
|
|||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
|
|
||||||
> <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" />
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -67,8 +64,8 @@ FFmpeg **已自动内置**,无需手动安装。
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 下载项目
|
# 下载项目
|
||||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||||
cd teamspeak-music-bot
|
cd tsmusicbot
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
npm install
|
npm install
|
||||||
@@ -88,8 +85,8 @@ npm start
|
|||||||
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||||
cd teamspeak-music-bot/scripts/docker
|
cd tsmusicbot/scripts/docker
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,96 +124,6 @@ sudo ./scripts/install.sh
|
|||||||
|
|
||||||
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
|
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
|
||||||
|
|
||||||
## 更新升级
|
|
||||||
|
|
||||||
> **⚠️ 从使用 `@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 用户
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 双击 scripts\stop.bat 停止运行中的机器人(或手动关闭窗口)
|
|
||||||
2. 在项目目录打开命令行,执行 git pull
|
|
||||||
3. 双击 scripts\setup.bat 重新安装依赖并构建
|
|
||||||
4. 双击 scripts\start.bat 启动
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动安装用户(所有系统)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停止当前运行的机器人(Ctrl+C 或 kill 进程)
|
|
||||||
|
|
||||||
# 拉取最新代码
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 重新安装依赖(如有新增依赖)
|
|
||||||
npm install
|
|
||||||
cd web && npm install && cd ..
|
|
||||||
|
|
||||||
# 重新构建
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 启动
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker 用户
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd scripts/docker
|
|
||||||
|
|
||||||
# 拉取最新代码
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 重新构建并启动(数据自动保留)
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
> 数据(数据库、Cookie、日志)保存在 Docker 命名卷 `tsmusicbot-data` 中,更新不会丢失。
|
|
||||||
|
|
||||||
### Linux systemd 用户
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停止服务
|
|
||||||
sudo systemctl stop tsmusicbot
|
|
||||||
|
|
||||||
# 拉取最新代码
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 重新安装依赖并构建
|
|
||||||
npm install
|
|
||||||
cd web && npm install && cd ..
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 重新启动服务
|
|
||||||
sudo systemctl start tsmusicbot
|
|
||||||
```
|
|
||||||
|
|
||||||
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
|
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
### 首次配置
|
### 首次配置
|
||||||
@@ -246,7 +153,6 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!play <歌名>` | 搜索并播放 |
|
| `!play <歌名>` | 搜索并播放 |
|
||||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
|
||||||
| `!add <歌名>` | 添加到播放队列 |
|
| `!add <歌名>` | 添加到播放队列 |
|
||||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||||
@@ -281,7 +187,7 @@ sudo systemctl start tsmusicbot
|
|||||||
## 项目架构
|
## 项目架构
|
||||||
|
|
||||||
```
|
```
|
||||||
teamspeak-music-bot/
|
tsmusicbot/
|
||||||
├── src/ # 后端源码 (TypeScript)
|
├── src/ # 后端源码 (TypeScript)
|
||||||
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
||||||
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
||||||
@@ -290,8 +196,7 @@ teamspeak-music-bot/
|
|||||||
│ ├── bot/ # 机器人核心
|
│ ├── bot/ # 机器人核心
|
||||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||||
│ │ ├── manager.ts # 多实例生命周期管理
|
│ │ └── manager.ts # 多实例生命周期管理
|
||||||
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
|
||||||
│ ├── data/ # 数据层
|
│ ├── data/ # 数据层
|
||||||
│ │ ├── config.ts # JSON 配置文件
|
│ │ ├── config.ts # JSON 配置文件
|
||||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
||||||
@@ -300,14 +205,10 @@ teamspeak-music-bot/
|
|||||||
│ │ ├── netease.ts # 网易云音乐适配器
|
│ │ ├── netease.ts # 网易云音乐适配器
|
||||||
│ │ ├── qq.ts # QQ 音乐适配器
|
│ │ ├── qq.ts # QQ 音乐适配器
|
||||||
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
||||||
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
|
|
||||||
│ │ ├── auth.ts # Cookie 持久化存储
|
│ │ ├── auth.ts # Cookie 持久化存储
|
||||||
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
|
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
|
||||||
│ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议)
|
│ ├── ts-protocol/ # TS3 客户端协议
|
||||||
│ │ ├── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
|
│ │ └── 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 后端
|
│ ├── web/ # Web 后端
|
||||||
│ │ ├── server.ts # Express + WebSocket 服务
|
│ │ ├── server.ts # Express + WebSocket 服务
|
||||||
│ │ ├── websocket.ts # 实时状态广播
|
│ │ ├── websocket.ts # 实时状态广播
|
||||||
@@ -345,7 +246,7 @@ teamspeak-music-bot/
|
|||||||
| **后端框架** | Express 4, WebSocket (ws) |
|
| **后端框架** | Express 4, WebSocket (ws) |
|
||||||
| **数据库** | better-sqlite3 (SQLite) |
|
| **数据库** | better-sqlite3 (SQLite) |
|
||||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议,兼容 TS3/TS5/TS6) |
|
||||||
| **网易云 API** | NeteaseCloudMusicApi |
|
| **网易云 API** | NeteaseCloudMusicApi |
|
||||||
| **QQ 音乐 API** | @sansenjian/qq-music-api |
|
| **QQ 音乐 API** | @sansenjian/qq-music-api |
|
||||||
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
||||||
@@ -354,55 +255,6 @@ teamspeak-music-bot/
|
|||||||
| **图标** | @iconify/vue |
|
| **图标** | @iconify/vue |
|
||||||
| **日志** | pino |
|
| **日志** | 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` 在首次运行时自动生成,可手动编辑:
|
`config.json` 在首次运行时自动生成,可手动编辑:
|
||||||
@@ -425,9 +277,6 @@ pip install -U yt-dlp
|
|||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**Q:支持 TeamSpeak 6 Server 吗?**
|
|
||||||
A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。
|
|
||||||
|
|
||||||
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
|
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
|
||||||
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
|
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
|
||||||
|
|
||||||
@@ -456,9 +305,6 @@ A:原生模块(opus、sqlite3)需要编译工具,Dockerfile 已包含。
|
|||||||
**Q:B站视频搜索不到结果?**
|
**Q:B站视频搜索不到结果?**
|
||||||
A:B站搜索需要 buvid3 匿名 Cookie(程序启动时自动获取)。如果失败,重启程序即可。登录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:如何更新到新版本?**
|
**Q:如何更新到新版本?**
|
||||||
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
|
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
|
||||||
|
|
||||||
@@ -470,82 +316,12 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
|||||||
4. 推送分支 (`git push origin feature/新功能`)
|
4. 推送分支 (`git push origin feature/新功能`)
|
||||||
5. 提交 Pull Request
|
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 音频机器人框架 |
|
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
|
||||||
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
|
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
|
||||||
| [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 |
|
| [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 |
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
# 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
+349
-347
File diff suppressed because it is too large.
Load diff
+3
-5
@@ -8,16 +8,15 @@
|
|||||||
"build": "tsc && npm run build:web",
|
"build": "tsc && npm run build:web",
|
||||||
"build:web": "cd web && npm run build",
|
"build:web": "cd web && npm run build",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"play": "node dist/index.js",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@honeybbq/teamspeak-client": "^0.2.1",
|
"@honeybbq/teamspeak-client": "^0.1.0",
|
||||||
"@koa/router": "^15.4.0",
|
"@koa/router": "^15.4.0",
|
||||||
"@sansenjian/qq-music-api": "^2.2.10",
|
"@sansenjian/qq-music-api": "^2.2.9",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -30,8 +29,7 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"ts3-nodejs-library": "^3.5.1",
|
"ts3-nodejs-library": "^3.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0"
|
||||||
"yt-dlp-wrap": "^2.3.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|||||||
+13
-10
@@ -25,27 +25,30 @@ RUN cd web && npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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 ---
|
# --- Stage 2: Production image ---
|
||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
# Install only runtime native build tools (needed for native module rebuild)
|
||||||
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg && \
|
python3 make g++ && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built output and pre-compiled production node_modules from builder
|
# Copy built output
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/web/dist ./web/dist
|
COPY --from=builder /app/web/dist ./web/dist
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Data directory for database, cookies, logs
|
# Data directory for database, cookies, logs
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# TSMusicBot — Docker Compose
|
# TSMusicBot — Docker Compose
|
||||||
# 一键部署:docker-compose up -d
|
# 一键部署:docker-compose up -d
|
||||||
#
|
#
|
||||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
|
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
|
||||||
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
|
# 无需安装任何额外软件
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tsmusicbot:
|
tsmusicbot:
|
||||||
|
|||||||
+17
-41
@@ -6,19 +6,9 @@ echo "║ TSMusicBot Installer ║"
|
|||||||
echo "╚══════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════╝"
|
||||||
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"
|
INSTALL_DIR="/opt/tsmusicbot"
|
||||||
SERVICE_NAME="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
|
# Detect OS
|
||||||
if [ -f /etc/os-release ]; then
|
if [ -f /etc/os-release ]; then
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
@@ -28,57 +18,44 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[1/6] Installing system dependencies..."
|
echo "[1/5] Installing system dependencies..."
|
||||||
case $OS in
|
case $OS in
|
||||||
ubuntu|debian)
|
ubuntu|debian)
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y -qq curl build-essential python3
|
sudo apt-get install -y -qq curl ffmpeg
|
||||||
;;
|
;;
|
||||||
centos|rhel|fedora)
|
centos|rhel|fedora)
|
||||||
sudo yum install -y curl gcc gcc-c++ make python3
|
sudo yum install -y curl ffmpeg
|
||||||
;;
|
;;
|
||||||
arch|manjaro)
|
arch|manjaro)
|
||||||
sudo pacman -S --noconfirm curl base-devel python
|
sudo pacman -S --noconfirm curl ffmpeg
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
|
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "[2/6] Installing Node.js 20 LTS..."
|
echo "[2/5] Installing Node.js 20 LTS..."
|
||||||
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
|
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 -
|
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
|
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
|
||||||
fi
|
fi
|
||||||
echo "Node.js $(node -v) installed"
|
echo "Node.js $(node -v) installed"
|
||||||
|
|
||||||
echo "[3/6] Installing dependencies..."
|
echo "[3/5] Downloading TSMusicBot..."
|
||||||
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/6] Building project..."
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
echo "[5/6] Copying to $INSTALL_DIR..."
|
|
||||||
sudo mkdir -p "$INSTALL_DIR"
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
|
if [ -d "$(pwd)/dist" ]; then
|
||||||
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
|
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
|
||||||
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
|
else
|
||||||
# Copy web frontend if built
|
echo "Please run this script from the TSMusicBot source directory after building."
|
||||||
if [ -d "$PROJECT_DIR/web/dist" ]; then
|
exit 1
|
||||||
sudo mkdir -p "$INSTALL_DIR/web"
|
|
||||||
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
|
|
||||||
fi
|
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..."
|
echo "[4/5] Installing npm dependencies..."
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
sudo npm install --production
|
||||||
|
|
||||||
|
echo "[5/5] Creating systemd service..."
|
||||||
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
|
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=TSMusicBot - TeamSpeak Music Bot
|
Description=TSMusicBot - TeamSpeak Music Bot
|
||||||
@@ -111,5 +88,4 @@ echo "║ Commands: ║"
|
|||||||
echo "║ systemctl status tsmusicbot ║"
|
echo "║ systemctl status tsmusicbot ║"
|
||||||
echo "║ systemctl restart tsmusicbot ║"
|
echo "║ systemctl restart tsmusicbot ║"
|
||||||
echo "║ systemctl stop tsmusicbot ║"
|
echo "║ systemctl stop tsmusicbot ║"
|
||||||
echo "║ journalctl -u tsmusicbot -f ║"
|
|
||||||
echo "╚══════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════╝"
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""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()
|
|
||||||
+8
-71
@@ -35,39 +35,20 @@ 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 = (() => {
|
const resolvedFfmpeg: string = (() => {
|
||||||
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
|
if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
||||||
|
return ffmpegPath;
|
||||||
|
}
|
||||||
|
// Fall back to system ffmpeg
|
||||||
if (ffmpegWorks("ffmpeg")) {
|
if (ffmpegWorks("ffmpeg")) {
|
||||||
return "ffmpeg";
|
return "ffmpeg";
|
||||||
}
|
}
|
||||||
|
// Last resort: return whatever we have, will fail at runtime with clear error
|
||||||
// 2. Fall back to bundled ffmpeg-static binary
|
return ffmpegPath ?? "ffmpeg";
|
||||||
// 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";
|
|
||||||
|
|
||||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
|
||||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
|
||||||
return ffmpegPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
|
||||||
return "ffmpeg";
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
|
||||||
function getFfmpegCommand(): string {
|
function getFfmpegCommand(): string {
|
||||||
return resolvedFfmpeg;
|
return resolvedFfmpeg;
|
||||||
}
|
}
|
||||||
@@ -98,9 +79,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
||||||
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
||||||
private ffmpegPaused = false;
|
private ffmpegPaused = false;
|
||||||
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
|
|
||||||
private consecutiveFailures = 0;
|
|
||||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -116,18 +94,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.seekOffset = seekSeconds;
|
this.seekOffset = seekSeconds;
|
||||||
this.framesPlayed = 0;
|
this.framesPlayed = 0;
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
|
||||||
|
|
||||||
// Prevent rapid-fire spawn attempts when ffmpeg is broken
|
|
||||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
|
||||||
this.logger.error(
|
|
||||||
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
|
|
||||||
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
|
|
||||||
);
|
|
||||||
this.state = "idle";
|
|
||||||
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
||||||
|
|
||||||
@@ -162,14 +128,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
||||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
|
||||||
// Prevent stream errors from crashing the process
|
|
||||||
this.ffmpeg.stdout!.on("error", (err) => {
|
|
||||||
this.logger.warn({ err }, "FFmpeg stdout error");
|
|
||||||
});
|
|
||||||
this.ffmpeg.stderr!.on("error", (err) => {
|
|
||||||
this.logger.warn({ err }, "FFmpeg stderr error");
|
|
||||||
});
|
|
||||||
|
|
||||||
let gotFirstData = false;
|
let gotFirstData = false;
|
||||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||||
if (!gotFirstData) {
|
if (!gotFirstData) {
|
||||||
@@ -194,8 +152,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.ffmpeg.on("error", (err) => {
|
this.ffmpeg.on("error", (err) => {
|
||||||
this.logger.error({ err }, "FFmpeg error");
|
this.logger.error({ err }, "FFmpeg error");
|
||||||
if (this.sessionId === playSessionId) {
|
if (this.sessionId === playSessionId) {
|
||||||
this.spawnFailed = true;
|
|
||||||
this.consecutiveFailures++;
|
|
||||||
this.emit("error", err);
|
this.emit("error", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -246,14 +202,8 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
|
|
||||||
if (this.spawnFailed) {
|
|
||||||
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
|
|
||||||
} else {
|
|
||||||
this.consecutiveFailures = 0; // Reset on successful track completion
|
|
||||||
this.emit("trackEnd");
|
this.emit("trackEnd");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,13 +263,6 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
if (!this.currentUrl) return;
|
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.logger.info({ seek: seconds }, "Seeking");
|
||||||
this.play(this.currentUrl, seconds);
|
this.play(this.currentUrl, seconds);
|
||||||
}
|
}
|
||||||
@@ -352,18 +295,12 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.pcmBuffer = Buffer.alloc(0);
|
this.pcmBuffer = Buffer.alloc(0);
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
this.currentUrl = "";
|
this.currentUrl = "";
|
||||||
this.seekOffset = 0;
|
this.seekOffset = 0;
|
||||||
this.framesPlayed = 0;
|
this.framesPlayed = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the consecutive failure counter (e.g. after user action) */
|
|
||||||
resetFailures(): void {
|
|
||||||
this.consecutiveFailures = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(vol: number): void {
|
setVolume(vol: number): void {
|
||||||
this.volume = Math.max(0, Math.min(100, vol));
|
this.volume = Math.max(0, Math.min(100, vol));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,49 +89,6 @@ describe("PlayQueue", () => {
|
|||||||
expect(queue.list()[1].id).toBe("3");
|
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", () => {
|
it("clears all songs", () => {
|
||||||
queue.add(makeSong("1"));
|
queue.add(makeSong("1"));
|
||||||
queue.add(makeSong("2"));
|
queue.add(makeSong("2"));
|
||||||
@@ -150,101 +107,6 @@ describe("PlayQueue", () => {
|
|||||||
expect(next).not.toBeNull();
|
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", () => {
|
it("random-loop mode never returns null", () => {
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
queue.add(makeSong("1"));
|
queue.add(makeSong("1"));
|
||||||
|
|||||||
+8
-28
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
|||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili";
|
||||||
url?: string; // resolved lazily at play time
|
url?: string; // resolved lazily at play time
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
@@ -20,7 +20,6 @@ export class PlayQueue {
|
|||||||
private songs: QueuedSong[] = [];
|
private songs: QueuedSong[] = [];
|
||||||
private currentIndex = -1;
|
private currentIndex = -1;
|
||||||
private mode: PlayMode = PlayMode.Sequential;
|
private mode: PlayMode = PlayMode.Sequential;
|
||||||
private playedIndices = new Set<number>();
|
|
||||||
|
|
||||||
add(song: QueuedSong): void {
|
add(song: QueuedSong): void {
|
||||||
this.songs.push(song);
|
this.songs.push(song);
|
||||||
@@ -37,16 +36,10 @@ export class PlayQueue {
|
|||||||
if (index < this.currentIndex) {
|
if (index < this.currentIndex) {
|
||||||
this.currentIndex--;
|
this.currentIndex--;
|
||||||
} else if (index === this.currentIndex) {
|
} else if (index === this.currentIndex) {
|
||||||
this.currentIndex--;
|
if (this.currentIndex >= this.songs.length) {
|
||||||
|
this.currentIndex = this.songs.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
return removed;
|
||||||
}
|
}
|
||||||
@@ -54,22 +47,17 @@ export class PlayQueue {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
this.songs = [];
|
this.songs = [];
|
||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
this.playedIndices.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): QueuedSong | null {
|
play(): QueuedSong | null {
|
||||||
if (this.songs.length === 0) return null;
|
if (this.songs.length === 0) return null;
|
||||||
this.playedIndices.clear();
|
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.playedIndices.add(0);
|
|
||||||
return this.songs[0];
|
return this.songs[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
playAt(index: number): QueuedSong | null {
|
playAt(index: number): QueuedSong | null {
|
||||||
if (index < 0 || index >= this.songs.length) return null;
|
if (index < 0 || index >= this.songs.length) return null;
|
||||||
this.playedIndices.clear();
|
|
||||||
this.currentIndex = index;
|
this.currentIndex = index;
|
||||||
this.playedIndices.add(index);
|
|
||||||
return this.songs[index];
|
return this.songs[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,15 +76,12 @@ export class PlayQueue {
|
|||||||
return this.songs[this.currentIndex];
|
return this.songs[this.currentIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.Random: {
|
case PlayMode.Random: {
|
||||||
const unplayed: number[] = [];
|
if (this.songs.length === 1) return this.songs[0];
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
let nextIndex: number;
|
||||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
do {
|
||||||
}
|
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||||
if (unplayed.length === 0) return null;
|
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||||
const nextIndex =
|
|
||||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
|
||||||
this.currentIndex = nextIndex;
|
this.currentIndex = nextIndex;
|
||||||
this.playedIndices.add(nextIndex);
|
|
||||||
return this.songs[nextIndex];
|
return this.songs[nextIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.RandomLoop: {
|
case PlayMode.RandomLoop: {
|
||||||
@@ -124,7 +109,6 @@ export class PlayQueue {
|
|||||||
} else {
|
} else {
|
||||||
this.currentIndex = prevIndex;
|
this.currentIndex = prevIndex;
|
||||||
}
|
}
|
||||||
this.playedIndices.add(this.currentIndex);
|
|
||||||
return this.songs[this.currentIndex];
|
return this.songs[this.currentIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +136,6 @@ export class PlayQueue {
|
|||||||
|
|
||||||
setMode(mode: PlayMode): void {
|
setMode(mode: PlayMode): void {
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.playedIndices.clear();
|
|
||||||
if (this.currentIndex >= 0) {
|
|
||||||
this.playedIndices.add(this.currentIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentIndex(): number {
|
getCurrentIndex(): number {
|
||||||
|
|||||||
+41
-138
@@ -13,9 +13,8 @@ import {
|
|||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,7 +23,6 @@ export interface BotInstanceOptions {
|
|||||||
neteaseProvider: MusicProvider;
|
neteaseProvider: MusicProvider;
|
||||||
qqProvider: MusicProvider;
|
qqProvider: MusicProvider;
|
||||||
bilibiliProvider: MusicProvider;
|
bilibiliProvider: MusicProvider;
|
||||||
youtubeProvider: MusicProvider;
|
|
||||||
database: BotDatabase;
|
database: BotDatabase;
|
||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -53,15 +51,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
private neteaseProvider: MusicProvider;
|
private neteaseProvider: MusicProvider;
|
||||||
private qqProvider: MusicProvider;
|
private qqProvider: MusicProvider;
|
||||||
private bilibiliProvider: MusicProvider;
|
private bilibiliProvider: MusicProvider;
|
||||||
private youtubeProvider: MusicProvider;
|
|
||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private disconnectEmitted = false;
|
|
||||||
private voteSkipUsers = new Set<string>();
|
private voteSkipUsers = new Set<string>();
|
||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
private profileManager: BotProfileManager;
|
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -70,7 +65,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.neteaseProvider = options.neteaseProvider;
|
this.neteaseProvider = options.neteaseProvider;
|
||||||
this.qqProvider = options.qqProvider;
|
this.qqProvider = options.qqProvider;
|
||||||
this.bilibiliProvider = options.bilibiliProvider;
|
this.bilibiliProvider = options.bilibiliProvider;
|
||||||
this.youtubeProvider = options.youtubeProvider;
|
|
||||||
this.database = options.database;
|
this.database = options.database;
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
this.logger = options.logger.child({ botId: this.id });
|
this.logger = options.logger.child({ botId: this.id });
|
||||||
@@ -79,14 +73,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
this.queue = new PlayQueue();
|
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.setupPlayerEvents();
|
||||||
this.setupTsEvents();
|
this.setupTsEvents();
|
||||||
}
|
}
|
||||||
@@ -113,49 +99,27 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private setupTsEvents(): void {
|
private setupTsEvents(): void {
|
||||||
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
||||||
this.handleTextMessage(msg).catch((err) => {
|
this.handleTextMessage(msg);
|
||||||
this.logger.error({ err }, "Unhandled error in text message handler");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.tsClient.on("disconnected", () => {
|
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.connected = false;
|
||||||
this.player.stop();
|
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");
|
this.emit("disconnected");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
this.disconnectEmitted = false;
|
|
||||||
await this.tsClient.connect();
|
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.connected = true;
|
||||||
this.profileManager.onConnect();
|
|
||||||
this.emit("connected");
|
this.emit("connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.connected = false;
|
|
||||||
if (!this.disconnectEmitted) {
|
|
||||||
this.disconnectEmitted = true;
|
|
||||||
this.emit("disconnected");
|
|
||||||
}
|
|
||||||
this.tsClient.disconnect();
|
this.tsClient.disconnect();
|
||||||
|
this.connected = false;
|
||||||
|
this.emit("disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
||||||
@@ -180,12 +144,13 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (response) {
|
if (response) {
|
||||||
await this.tsClient.sendTextMessage(response);
|
await this.tsClient.sendTextMessage(response);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||||
|
const friendlyMsg = err?.code === "ECONNREFUSED"
|
||||||
|
? "Error: Music API server is not reachable. Please check if the service is running."
|
||||||
|
: `Error: ${(err as Error).message}`;
|
||||||
try {
|
try {
|
||||||
await this.tsClient.sendTextMessage(
|
await this.tsClient.sendTextMessage(friendlyMsg);
|
||||||
`Error: ${(err as Error).message}`
|
|
||||||
);
|
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
|
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
|
||||||
}
|
}
|
||||||
@@ -196,24 +161,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
cmd: ParsedCommand,
|
cmd: ParsedCommand,
|
||||||
msg?: TS3TextMessage
|
msg?: TS3TextMessage
|
||||||
): Promise<string | null> {
|
): 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) {
|
switch (cmd.name) {
|
||||||
case "play":
|
case "play":
|
||||||
return this.cmdPlay(cmd);
|
return this.cmdPlay(cmd);
|
||||||
@@ -264,29 +211,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
|
getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
|
||||||
if (platform === "bilibili") return this.bilibiliProvider;
|
if (platform === "bilibili") return this.bilibiliProvider;
|
||||||
if (platform === "youtube") return this.youtubeProvider;
|
|
||||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProvider(flags: Set<string>): MusicProvider {
|
private getProvider(flags: Set<string>): MusicProvider {
|
||||||
if (flags.has("b")) return this.bilibiliProvider;
|
if (flags.has("b")) return this.bilibiliProvider;
|
||||||
if (flags.has("q")) return this.qqProvider;
|
if (flags.has("q")) return this.qqProvider;
|
||||||
if (flags.has("y")) return this.youtubeProvider;
|
|
||||||
return this.neteaseProvider;
|
return this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
|
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
|
||||||
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
|
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);
|
const provider = this.getProviderFor(song.platform);
|
||||||
try {
|
try {
|
||||||
const url = await provider.getSongUrl(song.id);
|
const url = await provider.getSongUrl(song.id);
|
||||||
@@ -294,18 +231,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
|
||||||
return false;
|
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;
|
song.url = url;
|
||||||
this.player.play(url);
|
this.player.play(url);
|
||||||
this.database.addPlayHistory({
|
this.database.addPlayHistory({
|
||||||
@@ -317,10 +242,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
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");
|
this.emit("stateChange");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -332,7 +253,18 @@ export class BotInstance extends EventEmitter {
|
|||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
const result = await provider.search(cmd.args, 1);
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await provider.search(cmd.args, 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ECONNREFUSED") {
|
||||||
|
this.logger.error({ err, platform: provider.platform }, "Music API server is not reachable");
|
||||||
|
return `Music API server for ${provider.platform} is not running. Please check the service.`;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.songs.length === 0)
|
if (result.songs.length === 0)
|
||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
@@ -341,8 +273,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
// Reset failure counter on user-initiated play
|
|
||||||
this.player.resetFailures();
|
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
if (!ok) return `Cannot play: ${song.name}`;
|
if (!ok) return `Cannot play: ${song.name}`;
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${song.name} - ${song.artist}`;
|
||||||
@@ -351,25 +281,23 @@ export class BotInstance extends EventEmitter {
|
|||||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !add <song name>";
|
if (!cmd.args) return "Usage: !add <song name>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
const result = await provider.search(cmd.args, 1);
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await provider.search(cmd.args, 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ECONNREFUSED") {
|
||||||
|
this.logger.error({ err, platform: provider.platform }, "Music API server is not reachable");
|
||||||
|
return `Music API server for ${provider.platform} is not running. Please check the service.`;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.songs.length === 0)
|
if (result.songs.length === 0)
|
||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
const wasIdle = this.player.getState() === "idle";
|
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
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");
|
this.emit("stateChange");
|
||||||
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
||||||
}
|
}
|
||||||
@@ -389,9 +317,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
|
||||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
|
||||||
});
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Stopped and queue cleared";
|
return "Stopped and queue cleared";
|
||||||
}
|
}
|
||||||
@@ -442,9 +367,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
|
||||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
|
||||||
});
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Queue cleared";
|
return "Queue cleared";
|
||||||
}
|
}
|
||||||
@@ -527,11 +449,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (!msg) return "Vote can only be used in TeamSpeak";
|
if (!msg) return "Vote can only be used in TeamSpeak";
|
||||||
this.voteSkipUsers.add(msg.invokerUid);
|
this.voteSkipUsers.add(msg.invokerUid);
|
||||||
const clients = await this.tsClient.getClientsInChannel();
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
const totalUsers = clients.length - 1; // exclude the bot itself
|
const totalUsers = clients.length - 1;
|
||||||
// At least 1 vote is always required — otherwise a single voter in an
|
const needed = Math.ceil(totalUsers / 2);
|
||||||
// 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;
|
const votes = this.voteSkipUsers.size;
|
||||||
|
|
||||||
if (votes >= needed) {
|
if (votes >= needed) {
|
||||||
@@ -572,7 +491,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}play <song> — Search and play`,
|
`${p}play <song> — Search and play`,
|
||||||
`${p}play -q <song> — Search from QQ Music`,
|
`${p}play -q <song> — Search from QQ Music`,
|
||||||
`${p}play -b <song> — Search from BiliBili`,
|
`${p}play -b <song> — Search from BiliBili`,
|
||||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
|
||||||
`${p}add <song> — Add to queue`,
|
`${p}add <song> — Add to queue`,
|
||||||
`${p}pause/resume — Pause/resume`,
|
`${p}pause/resume — Pause/resume`,
|
||||||
`${p}next/prev — Next/previous`,
|
`${p}next/prev — Next/previous`,
|
||||||
@@ -591,31 +509,24 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async playNext(): Promise<void> {
|
private async playNext(): Promise<void> {
|
||||||
if (this.isAdvancing || !this.connected) return;
|
if (this.isAdvancing) return;
|
||||||
this.isAdvancing = true;
|
this.isAdvancing = true;
|
||||||
try {
|
try {
|
||||||
this.voteSkipUsers.clear();
|
this.voteSkipUsers.clear();
|
||||||
const next = this.queue.next();
|
const next = this.queue.next();
|
||||||
if (next) {
|
if (next) {
|
||||||
let started = await this.resolveAndPlay(next);
|
const ok = await this.resolveAndPlay(next);
|
||||||
if (!started) {
|
if (!ok) {
|
||||||
// Skip to next if URL resolve fails (up to 3 retries)
|
// Skip to next if URL resolve fails (up to 3 retries)
|
||||||
for (let i = 0; i < 3 && this.connected; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const retry = this.queue.next();
|
const retry = this.queue.next();
|
||||||
if (!retry) break;
|
if (!retry) break;
|
||||||
if (await this.resolveAndPlay(retry)) {
|
if (await this.resolveAndPlay(retry)) break;
|
||||||
started = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!started) {
|
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.profileManager.onSongChange(null).catch(() => {});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.profileManager.onSongChange(null).catch(() => {});
|
|
||||||
}
|
}
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -661,12 +572,4 @@ export class BotInstance extends EventEmitter {
|
|||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfileManager(): BotProfileManager {
|
|
||||||
return this.profileManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdentityExport(): string | undefined {
|
|
||||||
return this.tsClient.getIdentityExport();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+6
-150
@@ -1,53 +1,13 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import {
|
import {
|
||||||
BotInstance,
|
BotInstance,
|
||||||
type BotInstanceOptions,
|
type BotInstanceOptions,
|
||||||
} from "./instance.js";
|
} from "./instance.js";
|
||||||
import type { MusicProvider } from "../music/provider.js";
|
import type { MusicProvider } from "../music/provider.js";
|
||||||
import { YouTubeProvider } from "../music/youtube.js";
|
|
||||||
import type { BotDatabase } from "../data/database.js";
|
import type { BotDatabase } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import type { Logger } from "../logger.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 {
|
export interface CreateBotParams {
|
||||||
name: string;
|
name: string;
|
||||||
serverAddress: string;
|
serverAddress: string;
|
||||||
@@ -57,20 +17,13 @@ export interface CreateBotParams {
|
|||||||
defaultChannel?: string;
|
defaultChannel?: string;
|
||||||
channelPassword?: string;
|
channelPassword?: string;
|
||||||
autoStart?: boolean;
|
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 extends EventEmitter {
|
export class BotManager {
|
||||||
private bots = new Map<string, BotInstance>();
|
private bots = new Map<string, BotInstance>();
|
||||||
private neteaseProvider: MusicProvider;
|
private neteaseProvider: MusicProvider;
|
||||||
private qqProvider: MusicProvider;
|
private qqProvider: MusicProvider;
|
||||||
private bilibiliProvider: MusicProvider;
|
private bilibiliProvider: MusicProvider;
|
||||||
private youtubeProvider: MusicProvider;
|
|
||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@@ -83,11 +36,9 @@ export class BotManager extends EventEmitter {
|
|||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
) {
|
) {
|
||||||
super();
|
|
||||||
this.neteaseProvider = neteaseProvider;
|
this.neteaseProvider = neteaseProvider;
|
||||||
this.qqProvider = qqProvider;
|
this.qqProvider = qqProvider;
|
||||||
this.bilibiliProvider = bilibiliProvider;
|
this.bilibiliProvider = bilibiliProvider;
|
||||||
this.youtubeProvider = new YouTubeProvider();
|
|
||||||
this.database = database;
|
this.database = database;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
@@ -106,21 +57,16 @@ export class BotManager extends EventEmitter {
|
|||||||
nickname: params.nickname,
|
nickname: params.nickname,
|
||||||
defaultChannel: params.defaultChannel,
|
defaultChannel: params.defaultChannel,
|
||||||
channelPassword: params.channelPassword,
|
channelPassword: params.channelPassword,
|
||||||
serverPassword: params.serverPassword,
|
|
||||||
serverProtocol: params.serverProtocol,
|
|
||||||
ts6ApiKey: params.ts6ApiKey,
|
|
||||||
},
|
},
|
||||||
neteaseProvider: this.neteaseProvider,
|
neteaseProvider: this.neteaseProvider,
|
||||||
qqProvider: this.qqProvider,
|
qqProvider: this.qqProvider,
|
||||||
bilibiliProvider: this.bilibiliProvider,
|
bilibiliProvider: this.bilibiliProvider,
|
||||||
youtubeProvider: this.youtubeProvider,
|
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
this.emit("botInstance", bot);
|
|
||||||
|
|
||||||
this.database.saveBotInstance({
|
this.database.saveBotInstance({
|
||||||
id,
|
id,
|
||||||
@@ -131,9 +77,6 @@ export class BotManager extends EventEmitter {
|
|||||||
defaultChannel: params.defaultChannel ?? "",
|
defaultChannel: params.defaultChannel ?? "",
|
||||||
channelPassword: params.channelPassword ?? "",
|
channelPassword: params.channelPassword ?? "",
|
||||||
autoStart: params.autoStart ?? false,
|
autoStart: params.autoStart ?? false,
|
||||||
serverProtocol: params.serverProtocol ?? "",
|
|
||||||
ts6ApiKey: params.ts6ApiKey ?? "",
|
|
||||||
serverPassword: params.serverPassword ?? "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
|
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
|
||||||
@@ -147,7 +90,6 @@ export class BotManager extends EventEmitter {
|
|||||||
this.bots.delete(id);
|
this.bots.delete(id);
|
||||||
}
|
}
|
||||||
this.database.deleteBotInstance(id);
|
this.database.deleteBotInstance(id);
|
||||||
this.emit("botInstanceRemoved", id);
|
|
||||||
this.logger.info({ botId: id }, "Bot instance removed");
|
this.logger.info({ botId: id }, "Bot instance removed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +106,6 @@ export class BotManager extends EventEmitter {
|
|||||||
nickname: params.nickname ?? existing.nickname,
|
nickname: params.nickname ?? existing.nickname,
|
||||||
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
|
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
|
||||||
channelPassword: params.channelPassword ?? existing.channelPassword,
|
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)
|
// Update in-memory name immediately (other fields need reconnect)
|
||||||
const bot = this.bots.get(id);
|
const bot = this.bots.get(id);
|
||||||
@@ -189,110 +128,43 @@ export class BotManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startBot(id: string): Promise<void> {
|
async startBot(id: string): Promise<void> {
|
||||||
const oldBot = this.bots.get(id);
|
const bot = this.bots.get(id);
|
||||||
if (!oldBot) throw new Error(`Bot ${id} not found`);
|
if (!bot) throw new Error(`Bot ${id} not found`);
|
||||||
|
await bot.connect();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopBot(id: string): void {
|
stopBot(id: string): void {
|
||||||
const bot = this.bots.get(id);
|
const bot = this.bots.get(id);
|
||||||
if (!bot) throw new Error(`Bot ${id} not found`);
|
if (!bot) throw new Error(`Bot ${id} not found`);
|
||||||
bot.disconnect();
|
bot.disconnect();
|
||||||
|
|
||||||
// Mark as not autoStart so it stays stopped on Docker restart
|
|
||||||
const saved = this.database.getBotInstances().find((i) => i.id === id);
|
|
||||||
if (saved) {
|
|
||||||
this.database.saveBotInstance({ ...saved, autoStart: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSavedBots(): Promise<void> {
|
async loadSavedBots(): Promise<void> {
|
||||||
const savedInstances = this.database.getBotInstances();
|
const savedInstances = this.database.getBotInstances();
|
||||||
for (const saved of savedInstances) {
|
for (const saved of savedInstances) {
|
||||||
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
|
|
||||||
const bot = new BotInstance({
|
const bot = new BotInstance({
|
||||||
id: saved.id,
|
id: saved.id,
|
||||||
name: saved.name,
|
name: saved.name,
|
||||||
tsOptions: {
|
tsOptions: {
|
||||||
host: saved.serverAddress,
|
host: saved.serverAddress,
|
||||||
port: saved.serverPort,
|
port: saved.serverPort,
|
||||||
queryPort: proto === "ts6" ? 10080 : 10011,
|
queryPort: 10011,
|
||||||
nickname: saved.nickname,
|
nickname: saved.nickname,
|
||||||
identity: saved.identity || undefined,
|
|
||||||
defaultChannel: saved.defaultChannel || undefined,
|
defaultChannel: saved.defaultChannel || undefined,
|
||||||
channelPassword: saved.channelPassword || undefined,
|
channelPassword: saved.channelPassword || undefined,
|
||||||
serverPassword: saved.serverPassword || undefined,
|
|
||||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
|
||||||
ts6ApiKey: saved.ts6ApiKey || undefined,
|
|
||||||
},
|
},
|
||||||
neteaseProvider: this.neteaseProvider,
|
neteaseProvider: this.neteaseProvider,
|
||||||
qqProvider: this.qqProvider,
|
qqProvider: this.qqProvider,
|
||||||
bilibiliProvider: this.bilibiliProvider,
|
bilibiliProvider: this.bilibiliProvider,
|
||||||
youtubeProvider: this.youtubeProvider,
|
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(saved.id, bot);
|
this.bots.set(saved.id, bot);
|
||||||
this.emit("botInstance", bot);
|
|
||||||
|
|
||||||
// Only auto-connect bots that have autoStart enabled
|
// Auto-connect in background (non-blocking, won't affect other bots)
|
||||||
if (saved.autoStart) {
|
|
||||||
bot.connect().then(() => {
|
bot.connect().then(() => {
|
||||||
// Persist identity after successful connection for future restarts
|
|
||||||
this.persistBotIdentity(saved, bot);
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ botId: saved.id, name: saved.name },
|
{ botId: saved.id, name: saved.name },
|
||||||
"Auto-connected saved bot"
|
"Auto-connected saved bot"
|
||||||
@@ -303,15 +175,6 @@ export class BotManager extends EventEmitter {
|
|||||||
"Failed to auto-connect bot (start manually from Settings)"
|
"Failed to auto-connect bot (start manually from Settings)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stagger connections to avoid overwhelming the TS server
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
} else {
|
|
||||||
this.logger.info(
|
|
||||||
{ botId: saved.id, name: saved.name },
|
|
||||||
"Loaded bot (autoStart disabled, not connecting)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@@ -320,13 +183,6 @@ export class BotManager extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistBotIdentity(saved: import("../data/database.js").BotInstance, bot: BotInstance): void {
|
|
||||||
const identity = bot.getIdentityExport();
|
|
||||||
if (identity && identity !== saved.identity) {
|
|
||||||
this.database.saveBotInstance({ ...saved, identity });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
for (const bot of this.bots.values()) {
|
for (const bot of this.bots.values()) {
|
||||||
bot.disconnect();
|
bot.disconnect();
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
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,9 +60,6 @@ describe("database", () => {
|
|||||||
defaultChannel: "Music",
|
defaultChannel: "Music",
|
||||||
channelPassword: "",
|
channelPassword: "",
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
serverProtocol: "",
|
|
||||||
ts6ApiKey: "",
|
|
||||||
serverPassword: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
botDb.saveBotInstance(instance);
|
botDb.saveBotInstance(instance);
|
||||||
@@ -89,9 +86,6 @@ describe("database", () => {
|
|||||||
defaultChannel: "Music",
|
defaultChannel: "Music",
|
||||||
channelPassword: "",
|
channelPassword: "",
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
serverProtocol: "",
|
|
||||||
ts6ApiKey: "",
|
|
||||||
serverPassword: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(botDb.deleteBotInstance("bot1")).toBe(true);
|
expect(botDb.deleteBotInstance("bot1")).toBe(true);
|
||||||
|
|||||||
+7
-125
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
|
|||||||
songName: string;
|
songName: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili";
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,33 +24,8 @@ export interface BotInstance {
|
|||||||
defaultChannel: string;
|
defaultChannel: string;
|
||||||
channelPassword: string;
|
channelPassword: string;
|
||||||
autoStart: boolean;
|
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 {
|
export interface BotDatabase {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||||
@@ -58,42 +33,9 @@ export interface BotDatabase {
|
|||||||
saveBotInstance(instance: BotInstance): void;
|
saveBotInstance(instance: BotInstance): void;
|
||||||
getBotInstances(): BotInstance[];
|
getBotInstances(): BotInstance[];
|
||||||
deleteBotInstance(id: string): boolean;
|
deleteBotInstance(id: string): boolean;
|
||||||
getProfileConfig(botId: string): ProfileConfig;
|
|
||||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateSchema(db: Database.Database): void {
|
|
||||||
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
|
|
||||||
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 {
|
function initTables(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS play_history (
|
CREATE TABLE IF NOT EXISTS play_history (
|
||||||
@@ -116,11 +58,7 @@ function initTables(db: Database.Database): void {
|
|||||||
nickname TEXT NOT NULL,
|
nickname TEXT NOT NULL,
|
||||||
defaultChannel TEXT NOT NULL,
|
defaultChannel TEXT NOT NULL,
|
||||||
channelPassword TEXT NOT NULL,
|
channelPassword TEXT NOT NULL,
|
||||||
autoStart INTEGER NOT NULL DEFAULT 0,
|
autoStart INTEGER NOT NULL DEFAULT 0
|
||||||
serverProtocol TEXT NOT NULL DEFAULT '',
|
|
||||||
ts6ApiKey TEXT NOT NULL DEFAULT '',
|
|
||||||
serverPassword TEXT NOT NULL DEFAULT '',
|
|
||||||
identity TEXT
|
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -129,7 +67,6 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
initTables(db);
|
initTables(db);
|
||||||
migrateSchema(db);
|
|
||||||
|
|
||||||
const insertHistory = db.prepare(`
|
const insertHistory = db.prepare(`
|
||||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||||
@@ -141,8 +78,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const upsertInstance = db.prepare(`
|
const upsertInstance = db.prepare(`
|
||||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
|
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart)
|
||||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
|
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
serverAddress = excluded.serverAddress,
|
serverAddress = excluded.serverAddress,
|
||||||
@@ -150,35 +87,13 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
nickname = excluded.nickname,
|
nickname = excluded.nickname,
|
||||||
defaultChannel = excluded.defaultChannel,
|
defaultChannel = excluded.defaultChannel,
|
||||||
channelPassword = excluded.channelPassword,
|
channelPassword = excluded.channelPassword,
|
||||||
autoStart = excluded.autoStart,
|
autoStart = excluded.autoStart
|
||||||
serverProtocol = excluded.serverProtocol,
|
|
||||||
ts6ApiKey = excluded.ts6ApiKey,
|
|
||||||
serverPassword = excluded.serverPassword,
|
|
||||||
identity = excluded.identity
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
||||||
|
|
||||||
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
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 {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -194,22 +109,14 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
upsertInstance.run({
|
upsertInstance.run({
|
||||||
...instance,
|
...instance,
|
||||||
autoStart: instance.autoStart ? 1 : 0,
|
autoStart: instance.autoStart ? 1 : 0,
|
||||||
identity: instance.identity ?? null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getBotInstances() {
|
getBotInstances() {
|
||||||
const rows = selectInstances.all() as Array<
|
const rows = selectInstances.all() as Array<
|
||||||
Omit<BotInstance, "autoStart" | "identity"> & { autoStart: number; identity: string | null }
|
Omit<BotInstance, "autoStart"> & { autoStart: number }
|
||||||
>;
|
>;
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 }));
|
||||||
...r,
|
|
||||||
autoStart: r.autoStart === 1,
|
|
||||||
serverProtocol: r.serverProtocol ?? "",
|
|
||||||
ts6ApiKey: r.ts6ApiKey ?? "",
|
|
||||||
serverPassword: r.serverPassword ?? "",
|
|
||||||
identity: r.identity ?? undefined,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBotInstance(id) {
|
deleteBotInstance(id) {
|
||||||
@@ -217,31 +124,6 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return result.changes > 0;
|
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() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,14 +25,6 @@ async function main() {
|
|||||||
saveConfig(CONFIG_PATH, config);
|
saveConfig(CONFIG_PATH, config);
|
||||||
|
|
||||||
const logger = createLogger(LOG_DIR);
|
const logger = createLogger(LOG_DIR);
|
||||||
|
|
||||||
// Prevent unhandled errors from crashing the process
|
|
||||||
process.on("uncaughtException", (err) => {
|
|
||||||
logger.error({ err }, "Uncaught exception");
|
|
||||||
});
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
logger.error({ reason }, "Unhandled promise rejection");
|
|
||||||
});
|
|
||||||
const db = createDatabase(DB_PATH);
|
const db = createDatabase(DB_PATH);
|
||||||
|
|
||||||
const apiServer = createApiServerManager(
|
const apiServer = createApiServerManager(
|
||||||
|
|||||||
+3
-24
@@ -17,9 +17,7 @@ export interface ApiServerManager {
|
|||||||
function isPortFree(port: number): Promise<boolean> {
|
function isPortFree(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.once("error", () => {
|
server.once("error", () => resolve(false));
|
||||||
server.close(() => resolve(false));
|
|
||||||
});
|
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close(() => resolve(true));
|
server.close(() => resolve(true));
|
||||||
});
|
});
|
||||||
@@ -32,7 +30,6 @@ export function createApiServerManager(
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
): ApiServerManager {
|
): ApiServerManager {
|
||||||
let neteaseServer: Server | null = null;
|
let neteaseServer: Server | null = null;
|
||||||
let qqMusicServer: Server | null = null;
|
|
||||||
|
|
||||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||||
@@ -63,10 +60,7 @@ export function createApiServerManager(
|
|||||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start QQ Music API. Older versions auto-started on import; the
|
// Start QQ Music API (auto-starts on import)
|
||||||
// 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 {
|
try {
|
||||||
const portFree = await isPortFree(options.qqMusicPort);
|
const portFree = await isPortFree(options.qqMusicPort);
|
||||||
if (!portFree) {
|
if (!portFree) {
|
||||||
@@ -75,22 +69,11 @@ export function createApiServerManager(
|
|||||||
"QQ Music API port already in use — reusing existing instance"
|
"QQ Music API port already in use — reusing existing instance"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
await import("@sansenjian/qq-music-api");
|
||||||
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(
|
logger.info(
|
||||||
{ port: options.qqMusicPort },
|
{ port: options.qqMusicPort },
|
||||||
"QQ Music API started"
|
"QQ Music API started"
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
logger.warn("QQ Music API module does not expose a Koa app");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -106,10 +89,6 @@ export function createApiServerManager(
|
|||||||
(neteaseServer as any).close();
|
(neteaseServer as any).close();
|
||||||
}
|
}
|
||||||
neteaseServer = null;
|
neteaseServer = null;
|
||||||
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
|
|
||||||
(qqMusicServer as any).close();
|
|
||||||
}
|
|
||||||
qqMusicServer = null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getNeteaseBaseUrl(): string {
|
getNeteaseBaseUrl(): string {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface Song {
|
|||||||
album: string;
|
album: string;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SongWithUrl extends Song {
|
export interface SongWithUrl extends Song {
|
||||||
@@ -17,7 +17,7 @@ export interface Playlist {
|
|||||||
name: string;
|
name: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
@@ -26,7 +26,7 @@ export interface Album {
|
|||||||
artist: string;
|
artist: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
platform: "netease" | "qq" | "bilibili";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LyricLine {
|
export interface LyricLine {
|
||||||
@@ -54,7 +54,7 @@ export interface AuthStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MusicProvider {
|
export interface MusicProvider {
|
||||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
readonly platform: "netease" | "qq" | "bilibili";
|
||||||
|
|
||||||
search(query: string, limit?: number): Promise<SearchResult>;
|
search(query: string, limit?: number): Promise<SearchResult>;
|
||||||
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
||||||
|
|||||||
+21
-72
@@ -66,12 +66,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSongDetail(songId: string): Promise<Song | null> {
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
// Try /getSongInfo for full metadata, but fall through to a minimal
|
// getSongInfo requires cookie; use search as fallback
|
||||||
// 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 {
|
try {
|
||||||
const res = await this.api.get("/getSongInfo", {
|
const res = await this.api.get("/getSongInfo", {
|
||||||
params: { songmid: songId, ...this.cookieParams },
|
params: { songmid: songId, ...this.cookieParams },
|
||||||
@@ -92,20 +87,9 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to stub
|
// fallback: search by songmid (less reliable)
|
||||||
}
|
}
|
||||||
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
|
return null;
|
||||||
// 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[]> {
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
@@ -168,53 +152,30 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getQrCode(): Promise<QrCodeResult> {
|
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 res = await this.api.get("/getQQLoginQr");
|
||||||
const qrsig: string = res.data?.qrsig ?? "";
|
|
||||||
const ptqrtoken: string = String(res.data?.ptqrtoken ?? "");
|
|
||||||
return {
|
return {
|
||||||
qrUrl: "",
|
qrUrl: "",
|
||||||
qrImg: res.data?.img ?? "",
|
qrImg: res.data?.img ?? "",
|
||||||
key: `${qrsig}|${ptqrtoken}`,
|
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkQrCodeStatus(
|
async checkQrCodeStatus(
|
||||||
key: string
|
key: string
|
||||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
const [qrsig, ptqrtoken] = key.split("|");
|
const res = await this.api.get("/checkQQLoginQr", {
|
||||||
if (!qrsig || !ptqrtoken) return "expired";
|
params: { qrsig: key },
|
||||||
|
|
||||||
// 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 {
|
const code = res.data?.code ?? res.data?.response?.code;
|
||||||
return "expired";
|
if (code === 0) {
|
||||||
|
if (res.data?.cookie) {
|
||||||
|
this.cookie = res.data.cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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";
|
return "confirmed";
|
||||||
}
|
}
|
||||||
if (body?.refresh === true) return "expired";
|
if (code === 1) return "scanned";
|
||||||
if (typeof body?.message === "string" && body.message.includes("未扫描"))
|
if (code === 2) return "waiting";
|
||||||
return "waiting";
|
return "expired";
|
||||||
return "waiting";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie(cookie: string): void {
|
setCookie(cookie: string): void {
|
||||||
@@ -227,32 +188,20 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
async getAuthStatus(): Promise<AuthStatus> {
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
if (!this.cookie) return { loggedIn: false };
|
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 {
|
try {
|
||||||
const res = await this.api.get("/user/getUserPlaylists", {
|
const res = await this.api.get("/getUserAvatar", {
|
||||||
params: { uin, ...this.cookieParams },
|
params: { ...this.cookieParams },
|
||||||
});
|
});
|
||||||
if (res.data?.response?.code !== 0) return { loggedIn: false };
|
if (res.data?.response?.data) {
|
||||||
return {
|
return {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
nickname: `QQ ${uin}`,
|
nickname: res.data.response.data.nickname,
|
||||||
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100`,
|
avatarUrl: res.data.response.data.headpic,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
return { loggedIn: false };
|
return { loggedIn: false };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
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)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-182
@@ -1,5 +1,4 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { Readable } from "node:stream";
|
|
||||||
import {
|
import {
|
||||||
Client as TS3FullClient,
|
Client as TS3FullClient,
|
||||||
generateIdentity as genTS3Identity,
|
generateIdentity as genTS3Identity,
|
||||||
@@ -8,48 +7,23 @@ import {
|
|||||||
listChannels,
|
listChannels,
|
||||||
listClients,
|
listClients,
|
||||||
clientMove,
|
clientMove,
|
||||||
fileTransferDeleteFile,
|
|
||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
type FileUploadInfo,
|
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
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 { 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 {
|
export interface TS3ClientOptions {
|
||||||
host: string;
|
host: string;
|
||||||
port: number; // Voice/virtual server port (default 9987)
|
port: number; // Voice/virtual server port (default 9987)
|
||||||
queryPort: number; // ServerQuery port (10011 for TS3, 10080 for TS6 HTTP)
|
queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat
|
||||||
nickname: string;
|
nickname: string;
|
||||||
identity?: string; // Exported identity string, or undefined to generate new
|
identity?: string; // Exported identity string, or undefined to generate new
|
||||||
defaultChannel?: string;
|
defaultChannel?: string;
|
||||||
channelPassword?: string;
|
channelPassword?: string;
|
||||||
serverPassword?: 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 {
|
export interface TS3TextMessage {
|
||||||
@@ -66,9 +40,6 @@ export class TS3Client extends EventEmitter {
|
|||||||
private clientId = 0;
|
private clientId = 0;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private disconnecting = false;
|
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) {
|
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -81,105 +52,25 @@ 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> {
|
async connect(): Promise<void> {
|
||||||
// Clean up any existing connection before creating a new one
|
|
||||||
if (this.client) {
|
|
||||||
this.logger.info("Cleaning up previous connection before reconnecting");
|
|
||||||
try {
|
|
||||||
await this.client.disconnect();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during cleanup
|
|
||||||
}
|
|
||||||
this.client = null;
|
|
||||||
this.clientId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addr = `${this.options.host}:${this.options.port}`;
|
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)
|
// Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks)
|
||||||
let udpErrorCount = 0;
|
let udpErrorCount = 0;
|
||||||
|
let udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const throttledWarn = (msg: string, ...args: unknown[]) => {
|
const throttledWarn = (msg: string, ...args: unknown[]) => {
|
||||||
if (typeof msg === "string" && msg.includes("udp send error")) {
|
if (typeof msg === "string" && msg.includes("udp send error")) {
|
||||||
udpErrorCount++;
|
udpErrorCount++;
|
||||||
if (udpErrorCount === 1) {
|
if (udpErrorCount === 1) {
|
||||||
this.logger.warn(msg);
|
this.logger.warn(msg);
|
||||||
// After 2 seconds, log a summary and reset.
|
// After 2 seconds, log a summary and reset
|
||||||
// Clear any previous timer to avoid leaking it.
|
udpErrorTimer = setTimeout(() => {
|
||||||
if (this.udpErrorTimer) clearTimeout(this.udpErrorTimer);
|
|
||||||
this.udpErrorTimer = setTimeout(() => {
|
|
||||||
if (udpErrorCount > 1) {
|
if (udpErrorCount > 1) {
|
||||||
this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`);
|
this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`);
|
||||||
}
|
}
|
||||||
udpErrorCount = 0;
|
udpErrorCount = 0;
|
||||||
this.udpErrorTimer = null;
|
udpErrorTimer = null;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -221,19 +112,9 @@ export class TS3Client extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
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();
|
await this.client.waitConnected();
|
||||||
this.clientId = this.client.clientID();
|
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
|
// Join default channel if specified
|
||||||
if (this.options.defaultChannel) {
|
if (this.options.defaultChannel) {
|
||||||
@@ -294,56 +175,6 @@ 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;
|
private voiceFramesSent = 0;
|
||||||
|
|
||||||
sendVoiceData(opusFrame: Buffer): void {
|
sendVoiceData(opusFrame: Buffer): void {
|
||||||
@@ -381,12 +212,6 @@ export class TS3Client extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.clientId = 0;
|
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");
|
this.logger.info("Disconnected from TeamSpeak server");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
/**
|
|
||||||
* 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 net from "node:net";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js";
|
import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js";
|
||||||
|
|
||||||
export interface ConnectionOptions {
|
export interface ConnectionOptions {
|
||||||
host: string;
|
host: string;
|
||||||
port: number; // ServerQuery port: 10011 (TS3) — not available on TS6
|
port: number; // ServerQuery port, typically 10011
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandResult {
|
export interface CommandResult {
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
|
||||||
import type { CookieStore } from "../../music/auth.js";
|
import type { CookieStore } from "../../music/auth.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
@@ -12,13 +11,9 @@ export function createAuthRouter(
|
|||||||
cookieStore?: CookieStore
|
cookieStore?: CookieStore
|
||||||
): Router {
|
): Router {
|
||||||
const router = 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 {
|
function getProvider(platform?: string): MusicProvider {
|
||||||
if (platform === "bilibili") return bilibiliProvider;
|
if (platform === "bilibili") return bilibiliProvider;
|
||||||
if (platform === "youtube") return youtubeProvider;
|
|
||||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +119,6 @@ export function createAuthRouter(
|
|||||||
res.status(400).json({ error: "cookie is required" });
|
res.status(400).json({ error: "cookie is required" });
|
||||||
return;
|
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);
|
const provider = getProvider(platform);
|
||||||
provider.setCookie(cookie);
|
provider.setCookie(cookie);
|
||||||
const plat = platform === "bilibili" ? "bilibili" as const
|
const plat = platform === "bilibili" ? "bilibili" as const
|
||||||
|
|||||||
+2
-4
@@ -43,7 +43,6 @@ export function createBotRouter(
|
|||||||
nickname,
|
nickname,
|
||||||
defaultChannel,
|
defaultChannel,
|
||||||
channelPassword,
|
channelPassword,
|
||||||
serverPassword,
|
|
||||||
autoStart,
|
autoStart,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
if (!name || !serverAddress || !nickname) {
|
if (!name || !serverAddress || !nickname) {
|
||||||
@@ -59,7 +58,6 @@ export function createBotRouter(
|
|||||||
nickname,
|
nickname,
|
||||||
defaultChannel,
|
defaultChannel,
|
||||||
channelPassword,
|
channelPassword,
|
||||||
serverPassword,
|
|
||||||
autoStart: autoStart ?? false,
|
autoStart: autoStart ?? false,
|
||||||
});
|
});
|
||||||
res.status(201).json(bot.getStatus());
|
res.status(201).json(bot.getStatus());
|
||||||
@@ -77,10 +75,10 @@ export function createBotRouter(
|
|||||||
res.status(404).json({ error: "Bot not found" });
|
res.status(404).json({ error: "Bot not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
|
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body;
|
||||||
// Update in database
|
// Update in database
|
||||||
botManager.updateBot(req.params.id, {
|
botManager.updateBot(req.params.id, {
|
||||||
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
|
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword,
|
||||||
});
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
export function createMusicRouter(
|
export function createMusicRouter(
|
||||||
@@ -10,11 +9,9 @@ export function createMusicRouter(
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
|
||||||
|
|
||||||
function getProvider(platform?: string): MusicProvider {
|
function getProvider(platform?: string): MusicProvider {
|
||||||
if (platform === "bilibili") return bilibiliProvider;
|
if (platform === "bilibili") return bilibiliProvider;
|
||||||
if (platform === "youtube") return youtubeProvider;
|
|
||||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-89
@@ -25,14 +25,6 @@ export function createPlayerRouter(
|
|||||||
next();
|
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) => {
|
router.post("/:botId/play", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
@@ -41,7 +33,8 @@ export function createPlayerRouter(
|
|||||||
res.status(400).json({ error: "query is required" });
|
res.status(400).json({ error: "query is required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!");
|
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||||
|
const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!");
|
||||||
if (!cmd) {
|
if (!cmd) {
|
||||||
res.status(400).json({ error: "Invalid command" });
|
res.status(400).json({ error: "Invalid command" });
|
||||||
return;
|
return;
|
||||||
@@ -57,7 +50,8 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { query, platform } = req.body;
|
const { query, platform } = req.body;
|
||||||
const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!");
|
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||||
|
const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!");
|
||||||
if (!cmd) {
|
if (!cmd) {
|
||||||
res.status(400).json({ error: "Invalid command" });
|
res.status(400).json({ error: "Invalid command" });
|
||||||
return;
|
return;
|
||||||
@@ -91,21 +85,7 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { volume } = req.body;
|
const { volume } = req.body;
|
||||||
// Reject bad input with a proper 4xx instead of letting cmdVol
|
const cmd = parseCommand(`!vol ${volume}`, "!")!;
|
||||||
// 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);
|
const response = await bot.executeCommand(cmd);
|
||||||
res.json({ message: response });
|
res.json({ message: response });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -113,18 +93,10 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
|
||||||
|
|
||||||
router.post("/:botId/mode", async (req, res) => {
|
router.post("/:botId/mode", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { mode } = req.body;
|
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 cmd = parseCommand(`!mode ${mode}`, "!")!;
|
||||||
const response = await bot.executeCommand(cmd);
|
const response = await bot.executeCommand(cmd);
|
||||||
res.json({ message: response });
|
res.json({ message: response });
|
||||||
@@ -144,12 +116,8 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { position } = req.body; // seconds
|
const { position } = req.body; // seconds
|
||||||
// typeof NaN === "number" and NaN < 0 is false, so a plain range
|
if (typeof position !== "number" || position < 0) {
|
||||||
// check lets NaN/Infinity through and later corrupts seekOffset.
|
res.status(400).json({ error: "position (seconds) is required" });
|
||||||
if (typeof position !== "number" || !Number.isFinite(position) || position < 0) {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "position must be a finite non-negative number" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bot.getPlayer().seek(position);
|
bot.getPlayer().seek(position);
|
||||||
@@ -185,15 +153,7 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
// Validate the index BEFORE stopping current playback — otherwise an
|
bot.getPlayer().stop(); // Stop current playback first
|
||||||
// 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);
|
const song = queue.playAt(index);
|
||||||
if (!song) {
|
if (!song) {
|
||||||
res.status(400).json({ error: "Invalid queue index" });
|
res.status(400).json({ error: "Invalid queue index" });
|
||||||
@@ -214,8 +174,9 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
|
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||||
const cmd = parseCommand(
|
const cmd = parseCommand(
|
||||||
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
|
`!playlist ${flags} ${playlistId}`.trim(),
|
||||||
"!"
|
"!"
|
||||||
)!;
|
)!;
|
||||||
const response = await bot.executeCommand(cmd);
|
const response = await bot.executeCommand(cmd);
|
||||||
@@ -231,17 +192,14 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
// Use the bot's own provider lookup — it already knows about youtube,
|
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
// which the router's constructor params did not.
|
if (!provider) {
|
||||||
const provider = bot.getProviderFor(
|
res.status(500).json({ error: "Provider not available" });
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
return;
|
||||||
? platform
|
}
|
||||||
: "netease"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop current playback
|
// Stop current playback
|
||||||
bot.getPlayer().stop();
|
bot.getPlayer().stop();
|
||||||
bot.getPlayer().resetFailures();
|
|
||||||
|
|
||||||
const songs = await provider.getPlaylistSongs(playlistId);
|
const songs = await provider.getPlaylistSongs(playlistId);
|
||||||
if (songs.length === 0) {
|
if (songs.length === 0) {
|
||||||
@@ -281,11 +239,11 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
const provider = bot.getProviderFor(
|
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
if (!provider) {
|
||||||
? platform
|
res.status(500).json({ error: "Provider not available" });
|
||||||
: "netease"
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
const song = await provider.getSongDetail(songId);
|
const song = await provider.getSongDetail(songId);
|
||||||
if (!song) {
|
if (!song) {
|
||||||
@@ -298,7 +256,6 @@ export function createPlayerRouter(
|
|||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform });
|
||||||
queue.play();
|
queue.play();
|
||||||
|
|
||||||
bot.getPlayer().resetFailures();
|
|
||||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
res.json({ message: `Cannot play: ${song.name}` });
|
res.json({ message: `Cannot play: ${song.name}` });
|
||||||
@@ -316,11 +273,11 @@ export function createPlayerRouter(
|
|||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
const provider = bot.getProviderFor(
|
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
if (!provider) {
|
||||||
? platform
|
res.status(500).json({ error: "Provider not available" });
|
||||||
: "netease"
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
const song = await provider.getSongDetail(songId);
|
const song = await provider.getSongDetail(songId);
|
||||||
if (!song) {
|
if (!song) {
|
||||||
@@ -343,27 +300,6 @@ 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) => {
|
router.get("/:botId/history", (req, res) => {
|
||||||
if (!database) {
|
if (!database) {
|
||||||
res.json({ history: [] });
|
res.json({ history: [] });
|
||||||
|
|||||||
@@ -67,14 +67,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
server.on("error", (err) => {
|
|
||||||
logger.error({ err }, "HTTP server error");
|
|
||||||
});
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
wss.on("error", (err) => {
|
|
||||||
logger.error({ err }, "WebSocket server error");
|
|
||||||
});
|
|
||||||
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+13
-51
@@ -10,9 +10,8 @@ export function setupWebSocket(
|
|||||||
): () => void {
|
): () => void {
|
||||||
const clients = new Set<WebSocket>();
|
const clients = new Set<WebSocket>();
|
||||||
|
|
||||||
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
|
/** Track which bots already have listeners attached */
|
||||||
const attachedBots = new Map<string, {
|
const attachedBots = new Map<string, {
|
||||||
bot: BotInstance;
|
|
||||||
stateChange: () => void;
|
stateChange: () => void;
|
||||||
connected: () => void;
|
connected: () => void;
|
||||||
disconnected: () => void;
|
disconnected: () => void;
|
||||||
@@ -40,31 +39,13 @@ export function setupWebSocket(
|
|||||||
const message = JSON.stringify(data);
|
const message = JSON.stringify(data);
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
try {
|
|
||||||
client.send(message);
|
client.send(message);
|
||||||
} catch {
|
|
||||||
clients.delete(client);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
function attachBotListener(bot: BotInstance): void {
|
||||||
const existing = attachedBots.get(bot.id);
|
if (attachedBots.has(bot.id)) return;
|
||||||
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 = () => {
|
const onStateChange = () => {
|
||||||
broadcast({
|
broadcast({
|
||||||
@@ -96,7 +77,6 @@ export function setupWebSocket(
|
|||||||
bot.on("disconnected", onDisconnected);
|
bot.on("disconnected", onDisconnected);
|
||||||
|
|
||||||
attachedBots.set(bot.id, {
|
attachedBots.set(bot.id, {
|
||||||
bot,
|
|
||||||
stateChange: onStateChange,
|
stateChange: onStateChange,
|
||||||
connected: onConnected,
|
connected: onConnected,
|
||||||
disconnected: onDisconnected,
|
disconnected: onDisconnected,
|
||||||
@@ -110,39 +90,21 @@ export function setupWebSocket(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// React immediately when a bot instance is created or replaced
|
// Check for newly added bots periodically
|
||||||
const onBotInstance = (bot: BotInstance) => attachBotListener(bot);
|
const intervalId = setInterval(ensureAllBotsAttached, 5000);
|
||||||
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();
|
ensureAllBotsAttached();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
botManager.removeListener("botInstance", onBotInstance);
|
// Clean up named listeners
|
||||||
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
|
for (const bot of botManager.getAllBots()) {
|
||||||
// Clean up all attached listeners (detach from stored bot refs, not live map)
|
const listeners = attachedBots.get(bot.id);
|
||||||
for (const id of Array.from(attachedBots.keys())) {
|
if (listeners) {
|
||||||
detachBotListener(id);
|
bot.removeListener("stateChange", listeners.stateChange);
|
||||||
|
bot.removeListener("connected", listeners.connected);
|
||||||
|
bot.removeListener("disconnected", listeners.disconnected);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
attachedBots.clear();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+19
-128
@@ -9,22 +9,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
<!-- Multi-bot selector -->
|
||||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
|
||||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
<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 }" />
|
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
|
||||||
</button>
|
</button>
|
||||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||||
<div
|
<button
|
||||||
v-for="bot in store.bots"
|
v-for="bot in store.bots"
|
||||||
:key="bot.id"
|
:key="bot.id"
|
||||||
class="bot-dropdown-row"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bot-dropdown-item"
|
class="bot-dropdown-item"
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
@click="selectBot(bot.id)"
|
@click="selectBot(bot.id)"
|
||||||
@@ -36,22 +31,12 @@
|
|||||||
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||||||
<span v-else class="bot-offline-badge">离线</span>
|
<span v-else class="bot-offline-badge">离线</span>
|
||||||
</button>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<RouterLink to="/settings" class="settings-btn">
|
<RouterLink to="/settings" class="settings-btn">
|
||||||
@@ -70,36 +55,12 @@ const store = usePlayerStore();
|
|||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
function selectBot(id: string) {
|
function selectBot(id: string) {
|
||||||
store.setActiveBotId(id);
|
store.setActiveBotId(id);
|
||||||
dropdownOpen.value = false;
|
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) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
@@ -184,31 +145,22 @@ onUnmounted(() => {
|
|||||||
.bot-selector-btn {
|
.bot-selector-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 10px 20px;
|
padding: 6px 14px;
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
min-height: 44px;
|
transition: background var(--transition-fast);
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-state-mini {
|
|
||||||
font-size: 14px;
|
|
||||||
&.playing { color: #22c55e; }
|
|
||||||
&.paused { color: #eab308; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-chevron {
|
.bot-chevron {
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
@@ -218,8 +170,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-dot {
|
.bot-dot {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -230,7 +182,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-selector-name {
|
.bot-selector-name {
|
||||||
max-width: 160px;
|
max-width: 120px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -253,8 +205,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 0;
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -271,12 +222,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-dropdown-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-dropdown-name {
|
.bot-dropdown-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -285,47 +230,6 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
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-playing-badge,
|
||||||
.bot-paused-badge,
|
.bot-paused-badge,
|
||||||
.bot-idle-badge,
|
.bot-idle-badge,
|
||||||
@@ -357,21 +261,8 @@ onUnmounted(() => {
|
|||||||
color: var(--text-tertiary);
|
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 {
|
.settings-btn {
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<span class="song-name">{{ song.name }}</span>
|
<span class="song-name">{{ song.name }}</span>
|
||||||
<span
|
<span
|
||||||
class="platform-badge"
|
class="platform-badge"
|
||||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
|
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
|
||||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
|
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-artist">{{ song.artist }}</div>
|
<div class="song-artist">{{ song.artist }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,11 +118,6 @@ function formatDuration(seconds: number): string {
|
|||||||
color: #00a1d6;
|
color: #00a1d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-youtube {
|
|
||||||
background: rgba(255, 0, 0, 0.12);
|
|
||||||
color: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-artist {
|
.song-artist {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -56,13 +56,6 @@ export function useWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
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,12 +38,6 @@ const router = createRouter({
|
|||||||
name: 'setup',
|
name: 'setup',
|
||||||
component: () => import('../views/Setup.vue'),
|
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,14 +168,6 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (saved) this.theme = saved;
|
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() {
|
async fetchBots() {
|
||||||
const res = await axios.get('/api/bot');
|
const res = await axios.get('/api/bot');
|
||||||
this.bots = res.data.bots;
|
this.bots = res.data.bots;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<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,10 +76,6 @@
|
|||||||
<label>频道密码(可选)</label>
|
<label>频道密码(可选)</label>
|
||||||
<input v-model="editForm.channelPassword" class="input" type="password" />
|
<input v-model="editForm.channelPassword" class="input" type="password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>服务器密码(可选)</label>
|
|
||||||
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
||||||
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
||||||
@@ -112,10 +108,6 @@
|
|||||||
<label>默认频道(可选)</label>
|
<label>默认频道(可选)</label>
|
||||||
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
|
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>服务器密码(可选)</label>
|
|
||||||
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
|
||||||
</div>
|
|
||||||
<button class="btn-primary" @click="createBot">创建</button>
|
<button class="btn-primary" @click="createBot">创建</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -402,7 +394,6 @@ const newBotServer = ref('');
|
|||||||
const newBotPort = ref(9987);
|
const newBotPort = ref(9987);
|
||||||
const newBotNickname = ref('MusicBot');
|
const newBotNickname = ref('MusicBot');
|
||||||
const newBotChannel = ref('');
|
const newBotChannel = ref('');
|
||||||
const newBotServerPassword = ref('');
|
|
||||||
|
|
||||||
// Edit bot
|
// Edit bot
|
||||||
const editingBot = ref<string | null>(null);
|
const editingBot = ref<string | null>(null);
|
||||||
@@ -413,7 +404,6 @@ const editForm = reactive({
|
|||||||
nickname: '',
|
nickname: '',
|
||||||
defaultChannel: '',
|
defaultChannel: '',
|
||||||
channelPassword: '',
|
channelPassword: '',
|
||||||
serverPassword: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const neteaseCookie = ref('');
|
const neteaseCookie = ref('');
|
||||||
@@ -569,7 +559,6 @@ async function createBot() {
|
|||||||
serverPort: newBotPort.value || 9987,
|
serverPort: newBotPort.value || 9987,
|
||||||
nickname: newBotNickname.value || newBotName.value,
|
nickname: newBotNickname.value || newBotName.value,
|
||||||
defaultChannel: newBotChannel.value || undefined,
|
defaultChannel: newBotChannel.value || undefined,
|
||||||
serverPassword: newBotServerPassword.value || undefined,
|
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
});
|
});
|
||||||
newBotName.value = '';
|
newBotName.value = '';
|
||||||
@@ -577,7 +566,6 @@ async function createBot() {
|
|||||||
newBotPort.value = 9987;
|
newBotPort.value = 9987;
|
||||||
newBotNickname.value = 'MusicBot';
|
newBotNickname.value = 'MusicBot';
|
||||||
newBotChannel.value = '';
|
newBotChannel.value = '';
|
||||||
newBotServerPassword.value = '';
|
|
||||||
await store.fetchBots();
|
await store.fetchBots();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore
|
// Ignore
|
||||||
@@ -610,7 +598,6 @@ async function openEditBot(bot: any) {
|
|||||||
editForm.nickname = res.data.nickname ?? '';
|
editForm.nickname = res.data.nickname ?? '';
|
||||||
editForm.defaultChannel = res.data.defaultChannel ?? '';
|
editForm.defaultChannel = res.data.defaultChannel ?? '';
|
||||||
editForm.channelPassword = res.data.channelPassword ?? '';
|
editForm.channelPassword = res.data.channelPassword ?? '';
|
||||||
editForm.serverPassword = res.data.serverPassword ?? '';
|
|
||||||
} catch {
|
} catch {
|
||||||
// Config not found — use defaults
|
// Config not found — use defaults
|
||||||
editForm.serverAddress = '';
|
editForm.serverAddress = '';
|
||||||
@@ -618,7 +605,6 @@ async function openEditBot(bot: any) {
|
|||||||
editForm.nickname = bot.name;
|
editForm.nickname = bot.name;
|
||||||
editForm.defaultChannel = '';
|
editForm.defaultChannel = '';
|
||||||
editForm.channelPassword = '';
|
editForm.channelPassword = '';
|
||||||
editForm.serverPassword = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user