diff --git a/.gitignore b/.gitignore index 23a3dc6..7a588c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ config.json cookies/ .superpowers/ +/bin/ +scripts/navbar_bigger.png diff --git a/README.md b/README.md index 3dded19..dc17d10 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

- TSMusicBot + TSMusicBot

TSMusicBot

- TeamSpeak 3 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板 + TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板

@@ -16,14 +16,15 @@ + + +

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