Compare commits

...
42 Commits
Author SHA1 Message Date
TIANYAO ZHANG 64ecd92d7c Merge pull request #43 from FFatTiger/feature/fm-artist-playlist
feat: 新增 !artist 歌手循环播放、!playlist 歌单模糊搜索,修复 !fm 播放中断,QQ 音乐个人歌单支持
2026-04-29 12:21:26 +08:00
TIANYAO ZHANG f41cd888f9 Merge pull request #42 from NoSetViolin/main
issue #39: web增加一键清空播放列表功能
2026-04-29 12:19:55 +08:00
fattiger 4de8ac1810 fix(qq): bypass broken c.y.qq.com search API, use u.y.qq.com directly 2026-04-27 19:04:31 +08:00
fattiger f9e2a210b7 docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:15:36 +08:00
fattiger 62fb67933d docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:14:27 +08:00
fattiger 43f4dd94aa feat(qq): add getUserPlaylists support for QQ Music provider 2026-04-27 18:08:09 +08:00
fattiger 1a5bd74357 feat: add !artist command for artist-based loop playback 2026-04-27 17:40:35 +08:00
fattiger 31e5e08def feat: support playlist name fuzzy search in !playlist command 2026-04-27 17:31:50 +08:00
fattiger 5d74979af3 docs: clarify exhausted-queue refill is defensive-only in FM RandomLoop mode 2026-04-27 17:29:00 +08:00
fattiger 24da8cfc2e fix: FM auto-refill to prevent audio dropout after initial batch 2026-04-27 17:20:34 +08:00
fattiger e3b4e1634d fix(player): reset consecutiveFailures after sustained healthy playback 2026-04-27 17:09:49 +08:00
fattiger 2f186ec002 chore: add .worktrees/ to .gitignore 2026-04-27 17:04:26 +08:00
fattiger 08170a2574 docs: add implementation plan for FM fix, !artist, and playlist search 2026-04-27 17:01:03 +08:00
fattiger 7eb8477dad docs: add design spec for FM bug fix, !artist command, and playlist fuzzy search 2026-04-27 16:55:35 +08:00
阿梓喵_あずにゃん 2fb0cd2489 issue #39: web增加一键清空播放列表功能 2026-04-26 23:07:48 +08:00
TIANYAO ZHANG 8dcdf01129 Update setup.bat 2026-04-25 14:34:45 +08:00
TIANYAO ZHANG da26435385 Merge pull request #38 from NoSetViolin/main
fix #37
2026-04-21 21:11:54 +08:00
阿梓喵_あずにゃん 364112d94f fix: ffmpeg残留 每次只留一个ffmpeg进程 现已加入闭包校验 防止歌曲帧异常串入 2026-04-21 03:53:50 +08:00
阿梓喵_あずにゃん 82a23d291e ffmpeg进程堆积优化
ffmpeg进程堆积优化(*)
减小缓冲区
降低100音量响度

*此处仍有问题  没招了  每次切歌会有几帧前面一首歌的残留 我不知道哪里出问题了
2026-04-21 02:03:20 +08:00
阿梓喵_あずにゃん 47514f57aa fix #37 2026-04-20 23:48:46 +08:00
TIANYAO ZHANG 1bd6ab9975 Merge pull request #36 from ZHANGTIANYAO1/claude/start-music-api-servers-CQST1
Fix TS6 HTTP Query error handling and improve profile update logging
2026-04-18 00:26:33 +08:00
Claude 314d6ec955 fix(profile): validate TS6 HTTP status and stop escaping JSON body
Silent failure: logs showed "Client properties updated" / "Description
updated" / "Avatar updated" even though the bot's nickname never
changed and the avatar stayed as "loading image" on clients.

Root causes:
- TS6HttpQuery.clientUpdate ignored non-2xx responses, so 400 (bad
  parameter) and 403 (insufficient permission) were reported as success.
- updateClientProperties built TS3-escaped strings (\\s for space) then
  split them back into JSON props, so TS6 received literal backslashes
  and rejected the nickname silently.
- handleFeatureError only matched textual "permission" errors; HTTP
  4xx statuses weren't recognised and the feature retried every song.
- doAvatarUpload had no per-step logging, making it impossible to tell
  whether a broken avatar came from init, the TCP 30033 transfer, or
  the client_flag_avatar command.

Fixes:
- Add HttpQueryError (status/body/path); clientUpdate throws on non-2xx.
- Build a raw property map in updateClientProperties; escape only on
  the TS3 wire path.
- Log HTTP status and updated prop names on success.
- handleFeatureError now treats HTTP 400/401/403 as unrecoverable.
- Debug-log each step of doAvatarUpload plus bytes/elapsedMs on success.

https://claude.ai/code/session_018NrpGWbQQTrahUVXyea5Jy
2026-04-17 16:21:52 +00:00
TIANYAO ZHANG 66ae948371 Merge pull request #35 from ZHANGTIANYAO1/claude/start-music-api-servers-ZIruv
Add shareable bot links with public URL configuration
2026-04-18 00:09:31 +08:00
Claude fecda7cce3 fix(web): make bot-link usable on public IP with HTTP
The "复制专属链接" button silently failed on public-IP HTTP deployments
because navigator.clipboard requires a secure context. Now the link is
always revealed in a modal with a read-only input (select-all on focus),
so users can copy manually even when clipboard APIs and execCommand both
fail. The dialog still tries to auto-copy when possible.

Also adds a publicUrl config option that overrides window.location.origin
for link generation (useful behind reverse proxies / with custom domains),
exposed via GET /api/config/public-url, and a trustProxy flag so Express
honors X-Forwarded-* when fronted by nginx/Caddy/Cloudflare.

https://claude.ai/code/session_019FSX3S3UUcKEYWanYmoqUv
2026-04-17 16:08:00 +00:00
saopig1andClaude Opus 4.6 b9e42d543c fix(web): make bot-link copy work over plain HTTP
navigator.clipboard requires a secure context (HTTPS or localhost). For
users hosting the bot on a remote IP and accessing via http://<ip>:3000,
clipboard.writeText is undefined and the copy silently fails. Add a
hidden-textarea + execCommand('copy') fallback, with a final prompt()
fallback so the user can always grab the URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:26:05 +08:00
saopig1andClaude Opus 4.6 4406eeacbe fix(api-server): handle QQ Music API export differences across versions
The @sansenjian/qq-music-api module's export structure varies between
versions (2.2.10 vs 2.2.11+). Add fallback chain to find the Koa app:
try candidate.listen first, then candidate.default.listen.

Also resolve leftover merge conflict marker in instance.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:52:27 +08:00
TIANYAO ZHANG 0408f6ce3c Merge pull request #33 from NeoPecos/feature/idle-timeout
添加闲时自动退出管理功能
2026-04-13 20:46:58 +08:00
TIANYAO ZHANG 47b69a1f95 Merge pull request #29 from stanoswald/bugfix/pass-pwd-in-client
Forward server password to client opetion
2026-04-13 20:44:43 +08:00
NeoPecos 0e992f2f5f merge: resolve conflict, keep idleTimer and profileManager 2026-04-13 17:43:04 +08:00
NeoPecos 7785cc972b feat: add idle timeout setting to auto-disconnect bot when channel is empty 2026-04-13 17:27:51 +08:00
StanOswald 4b412e6277 Forward server password to client opetion 2026-04-13 02:04:05 +08:00
TIANYAO ZHANG f0a0ae5f2c Merge pull request #28 from ZHANGTIANYAO1/dev
Dev
2026-04-13 00:53:12 +08:00
saopig1andClaude Opus 4.6 a56dc5bc85 docs(readme): document bot profile auto-update feature
Add profile feature to features list, architecture tree, and changelog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:52:09 +08:00
saopig1 1a663156c3 Merge branch 'main' of https://github.com/ZHANGTIANYAO1/teamspeak-music-bot into dev 2026-04-13 00:49:40 +08:00
saopig1andClaude Opus 4.6 e92628a6b4 fix(profile): add timeout to clientedit, guard clearAvatar with generation
- Wrap clientedit (description) with 5s timeout to prevent blocking
  channel description and now-playing updates if the command hangs
- Check generation counter in clearAvatar to avoid clearing a newer
  song's avatar when stop→play happens in quick succession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:09 +08:00
saopig1andClaude Opus 4.6 b4ad78ce84 fix(profile): harden against race conditions and edge cases
- Add generation counter to prevent stale avatar uploads from
  overwriting newer song's profile when rapidly skipping tracks
- Fix nickname truncation to use UTF-8 byte length instead of JS
  string length (TS3 counts bytes, Chinese chars are 3 bytes)
- Add timeout to clearAvatar file transfer (was missing)
- Extract withTimeout helper to deduplicate timeout logic
- Add BiliBili CDN thumbnail resize support (@200w_200h)
- Bump generation on reconnect to discard in-flight updates from
  the old connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:44:57 +08:00
saopig1andClaude Opus 4.6 30aced6d36 fix(profile): use clientedit for description, add sendCommandNoWait
Description via clientupdate is rejected (error 1538) on TS3 full-client
protocol. Switch to clientedit on the bot's own clid, which is how
TS3AudioBot handles it. Requires b_client_modify_description permission.

Also add sendCommandNoWait to TS3Client for fire-and-forget commands
(clientupdate, channeledit) that don't return timely responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:39:36 +08:00
saopig1andClaude Opus 4.6 da5b34f5f4 feat(profile): auto-update bot avatar, nickname, and away status based on playing song
Add BotProfileManager that updates the bot's TeamSpeak presence when
songs change: album cover as avatar, song info in nickname, away status
toggled on stop/play. Each feature is independently configurable via
REST API and persisted to the database. Permission-safe — features that
fail due to insufficient server permissions are silently disabled until
reconnect. Description falls back to nickname display on TS3 (only
supported via TS6 HTTP Query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:14:29 +08:00
TIANYAO ZHANG 010aa8aa8c Update README.md 2026-04-13 00:11:41 +08:00
TIANYAO ZHANG 05edca4a9a Merge pull request #27 from ZHANGTIANYAO1/claude/fix-shuffle-loop-bug-FrIbI
Fix random mode to track played songs and prevent duplicates
2026-04-12 23:09:33 +08:00
Claude e2b1a5e055 fix: prevent skipped/duplicate songs in Random mode edge cases
Three corner cases fixed:

1. Removing the currently-playing song caused the next song in the array
   to be silently marked as "played" and skipped. Root cause: playedIndices
   was updated in next() by marking currentIndex, but after remove() shifts
   currentIndex, it pointed to the wrong song. Fix: mark songs as played
   at play-time (in play/playAt/next/prev) instead of at next-request-time.

2. Using prev() in Random mode could cause a song to play twice — the
   song navigated to via prev() was not recorded in playedIndices, so
   next() could randomly select it again. Fix: prev() now marks the
   returned song as played.

3. Switching to Random mode mid-playback could cause the current song to
   repeat because setMode() cleared playedIndices without preserving the
   currently-playing song. Fix: setMode() now re-adds currentIndex after
   clearing.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 15:02:53 +00:00
Claude 4eac2e4dde fix: Random mode now stops after all songs played instead of looping forever
In Random (shuffle) mode, the queue's next() method would return the same
song indefinitely when only one song was in the playlist, and never terminate
even with multiple songs. This happened because played songs were not tracked.

Added a playedIndices Set to track which songs have already been played in
Random mode. Once all songs have been played once, next() returns null to
stop playback — matching the expected behavior where Random plays each song
once in random order, while RandomLoop is the mode for infinite shuffling.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 14:53:15 +00:00
32 changed files with 2635 additions and 414 deletions

No files matched your search

+1
View File
@@ -6,5 +6,6 @@ dist/
config.json
cookies/
.superpowers/
.worktrees/
/bin/
scripts/navbar_bigger.png
+25 -4
View File
@@ -34,6 +34,7 @@
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
@@ -41,7 +42,8 @@
## 截图
> *截图即将添加*
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
## 快速开始
@@ -252,9 +254,11 @@ sudo systemctl start tsmusicbot
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <ID>` | 加载歌单 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 |
| `!fm` | 私人 FM(网易云) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -288,7 +292,8 @@ teamspeak-music-bot/
│ ├── bot/ # 机器人核心
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ └── manager.ts # 多实例生命周期管理
│ │ ├── manager.ts # 多实例生命周期管理
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
@@ -473,6 +478,22 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
**新命令 & FM 修复**
- **新增 `!artist <歌手名>` 命令**:搜索指定歌手的歌曲并循环播放,支持 `-q`(QQ 音乐)/ `-b`(B站)/ `-y`(YouTube)平台切换。一次加载最多 50 首,自动按歌手名过滤并设为 Loop 模式。
- **歌单模糊搜索**:`!playlist` 现在支持歌单名称模糊搜索(如 `!playlist 华语经典`),自动匹配公开歌单 + 个人歌单(网易云 + QQ)。纯数字 ID 和 URL 解析保持兼容。
- **修复 `!fm` 播放中断**:私人 FM 几首歌后静音的 bug 已修复。新增自动续播机制(队列低位自动拉取新歌),播放器健康帧追踪防止临时 URL 失败导致永久静音。
- **QQ 音乐个人歌单**:QQ Music provider 新增 `getUserPlaylists` 支持,登录后可通过 `!playlist -q <名称>` 模糊搜索个人歌单。
**协议层 & 稳定性**
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
@@ -0,0 +1,517 @@
# FM Bug Fix + Artist Loop + Playlist Fuzzy Search — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix FM audio dropout bug, add `!artist` command for artist-based loop playback, and support playlist name fuzzy search in `!playlist`.
**Architecture:** All changes stay within existing files. The FM fix adds auto-refill logic and a success-tracking mechanism in the player. Playlist search reuses the existing `provider.search()` API that already returns playlists. The `!artist` command is a new command method following the same pattern as `cmdPlay`/`cmdFm`.
**Tech Stack:** TypeScript, Node.js, ffmpeg-static, @honeybbq/teamspeak-client
---
## File Map
| File | Change | Purpose |
|------|--------|---------|
| `src/bot/instance.ts` | Modify | Add `isFmMode`, `refillFm()`, fix `cmdFm()`, modify `cmdPlaylist()`, add `cmdArtist()`, modify `playNext()` to trigger FM refill |
| `src/bot/commands.ts` | Modify | Register `artist` in PUBLIC_COMMANDS, update help text |
| `src/audio/player.ts` | Modify | Track healthy frame count, reset `consecutiveFailures` after sustained successful playback |
---
### Task 1: Fix FM — Track healthy playback in AudioPlayer
**Files:**
- Modify: `src/audio/player.ts:62-82` (add field)
- Modify: `src/audio/player.ts:243-261` (sendNextFrame — track healthy frames)
- [ ] **Step 1: Add healthy frame counter field**
In `src/audio/player.ts`, after the `consecutiveFailures` field (line ~80), add:
```typescript
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
```
- [ ] **Step 2: Track healthy frames and reset failures in sendNextFrame**
In `src/audio/player.ts`, in the `sendNextFrame()` method, after line 257 (`this.framesPlayed++;`), add:
```typescript
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
```
- [ ] **Step 3: Reset healthyFrames in play() and stop()**
In `play()`, after `this.framesPlayed = 0;` (line ~95), add:
```typescript
this.healthyFrames = 0;
```
In `stop()`, after `this.framesPlayed = 0;` (line ~181), add:
```typescript
this.healthyFrames = 0;
```
- [ ] **Step 4: Commit**
```bash
git add src/audio/player.ts
git commit -m "fix(player): reset consecutiveFailures after sustained healthy playback"
```
---
### Task 2: Fix FM — Add auto-refill logic in BotInstance
**Files:**
- Modify: `src/bot/instance.ts:62-66` (add fields)
- Modify: `src/bot/instance.ts:557-573` (cmdFm)
- Modify: `src/bot/instance.ts:642-673` (playNext — add refill trigger)
- [ ] **Step 1: Add isFmMode field**
In `src/bot/instance.ts`, after `private profileManager: BotProfileManager;` (line ~66), add:
```typescript
private isFmMode = false;
```
- [ ] **Step 2: Add refillFm method**
In `src/bot/instance.ts`, before the `cmdVote` method (after `cmdFm`'s closing brace), add:
```typescript
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
}
```
- [ ] **Step 3: Modify cmdFm to set isFmMode and use RandomLoop**
Replace the existing `cmdFm` method (lines 557-573) with:
```typescript
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return "Personal FM is only available for NetEase Cloud Music";
}
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0)
return "No FM songs available (need to login first)";
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.queue.setMode(PlayMode.RandomLoop);
this.isFmMode = true;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
```
- [ ] **Step 4: Modify playNext to trigger FM refill and check isFmMode**
In `playNext()`, replace the `else` branch (lines 665-668) that handles `queue.next() === null`:
```typescript
} else {
// FM mode: try to refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
const started = await this.resolveAndPlay(refillNext);
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
this.emit("stateChange");
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
```
Also add a proactive refill after successful advance. At the end of the `if (next)` block, after `this.emit("stateChange");` is handled outside the if/else, add this right after `resolveAndPlay` succeeds (inside the `if (next)` block, after the retry loop):
After the `if (!started)` block and before the closing `}` of `if (next)`, insert:
```typescript
// Proactive FM refill when running low
if (this.isFmMode && this.queue.size() - (this.queue.getCurrentIndex()) <= 3) {
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
```
Wait — `this.emit("stateChange")` is outside the `if (next)` block. Let me re-read the original code structure...
The original `playNext()` structure is:
```
if (next) {
let started = await resolveAndPlay(next)
if (!started) { retry loop... }
if (!started) { stop }
} else {
stop
}
emit("stateChange")
```
So I need to add the proactive refill inside the `if (next)` block, right after `resolveAndPlay` succeeds. Let me write this more carefully:
```typescript
private async playNext(): Promise<void> {
if (this.isAdvancing || !this.connected) return;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
let started = await this.resolveAndPlay(next);
if (!started) {
for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
if (await this.resolveAndPlay(retry)) {
started = true;
break;
}
}
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
// Proactive refill: when queue is running low, fetch more FM songs
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
} else {
// Queue exhausted — in FM mode, refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
await this.resolveAndPlay(refillNext);
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
this.emit("stateChange");
} finally {
this.isAdvancing = false;
}
}
```
OK this is getting complex. Let me simplify the plan — I'll structure it more clearly.
Also I need to clear `isFmMode` when user issues stop/clear or manually plays something else.
- [ ] **Step 5: Clear isFmMode in stop/clear/play commands**
In `cmdStop()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdClear()` (same line), add:
```typescript
this.isFmMode = false;
```
In `cmdPlay()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdPlaylist()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdAlbum()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/instance.ts
git commit -m "fix: FM auto-refill to prevent audio dropout after initial batch"
```
---
### Task 3: Playlist Fuzzy Search
**Files:**
- Modify: `src/bot/instance.ts:524-539` (cmdPlaylist)
- [ ] **Step 1: Modify cmdPlaylist to support name search**
Replace the `cmdPlaylist` method (lines 524-539) with:
```typescript
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let playlistId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use existing logic
playlistId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
let playlists = result.playlists ?? [];
// Also search user's personal playlists if logged in
if (provider.getUserPlaylists) {
try {
const userPlaylists = await provider.getUserPlaylists();
const query = cmd.args.toLowerCase();
const matched = userPlaylists.filter(
p => p.name.toLowerCase().includes(query)
);
// Merge: public results first (API-ranked), then user matches
playlists = [...playlists, ...matched];
} catch {
// User playlists unavailable — continue with public results
}
}
if (playlists.length === 0)
return `No playlists found for: ${cmd.args}`;
playlistId = playlists[0].id;
}
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 2: Update help text to reflect new usage**
In `cmdHelp()` (line ~633), change the playlist line from:
```
`${p}playlist <id> — Load playlist`
```
to:
```
`${p}playlist <name or id> — Load playlist by name or ID`
```
- [ ] **Step 3: Commit**
```bash
git add src/bot/instance.ts
git commit -m "feat: support playlist name fuzzy search in !playlist command"
```
---
### Task 4: Artist Loop Command
**Files:**
- Modify: `src/bot/commands.ts:8-11` (PUBLIC_COMMANDS)
- Modify: `src/bot/commands.ts:248-260` (AUDIO_COMMANDS in instance.ts — actually in instance.ts)
- Modify: `src/bot/instance.ts:244-314` (executeCommand switch + add cmdArtist)
Wait, AUDIO_COMMANDS is in instance.ts executeCommand. Let me check...
Actually looking back at instance.ts, the AUDIO_COMMANDS set is local to executeCommand. I don't need to add artist there since it will be handled in the switch.
- [ ] **Step 1: Register `artist` in PUBLIC_COMMANDS**
In `src/bot/commands.ts`, line 9, add `"artist"` to the PUBLIC_COMMANDS set:
```typescript
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
```
- [ ] **Step 2: Add `artist` to the AUDIO_COMMANDS set in executeCommand**
In `src/bot/instance.ts`, in the `executeCommand` method, add `"artist"` to the AUDIO_COMMANDS set (line ~253):
```typescript
const AUDIO_COMMANDS = new Set([
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
"artist",
]);
```
- [ ] **Step 3: Add `artist` case to the switch in executeCommand**
In `src/bot/instance.ts`, after the `case "fm":` block (line ~300), add:
```typescript
case "artist":
return this.cmdArtist(cmd);
```
- [ ] **Step 4: Implement cmdArtist method**
Add the `cmdArtist` method in `src/bot/instance.ts`, after `cmdFm()`:
```typescript
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
if (result.songs.length === 0)
return `No results found for artist: ${cmd.args}`;
const query = cmd.args.toLowerCase();
let filtered = result.songs.filter(
s => s.artist.toLowerCase().includes(query)
);
// Fallback to unfiltered results if filtering drops everything
if (filtered.length === 0) {
filtered = result.songs.slice(0, 20);
}
this.queue.clear();
this.isFmMode = false;
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 5: Update help text**
In `cmdHelp()`, add the artist help line after the fm line:
```
`${p}artist <name> — Play songs by artist (loop)`
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/commands.ts src/bot/instance.ts
git commit -m "feat: add !artist command for artist-based loop playback"
```
---
### Task 5: Type-check and verify
**Files:**
- All modified files
- [ ] **Step 1: Run type check**
```bash
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
```
Expected: No errors.
- [ ] **Step 2: Verify command parsing**
```bash
cd /home/proxxy/project/teamspeak-music-bot && node --loader ts-node/esm -e "
const { parseCommand } = await import('./src/bot/commands.ts');
console.log(parseCommand('!artist 周杰伦', '!'));
console.log(parseCommand('!artist 周杰伦 -q', '!'));
console.log(parseCommand('!playlist 华语经典', '!'));
console.log(parseCommand('!playlist 123456', '!'));
"
```
Expected: All parse correctly; `artist` with args "周杰伦", `playlist` with args "华语经典" and "123456".
- [ ] **Step 3: Commit any fixes from type check**
```bash
git add -A && git commit -m "chore: type fixes from final verification"
```
(Only if there were issues)
---
### Self-Review Checklist
1. **Spec coverage:**
- FM bug fix → Tasks 1, 2 (healthy frame tracking + auto-refill)
- Playlist fuzzy search → Task 3
- Artist loop → Task 4
- Verification → Task 5
2. **No placeholders** — all steps have exact code.
3. **Type consistency:**
- `isFmMode: boolean` — used in cmdFm, cmdStop, cmdClear, cmdPlay, cmdPlaylist, cmdAlbum, playNext, refillFm ✓
- `refillFm(): Promise<void>` — called from cmdFm (indirectly via playNext trigger), playNext ✓
- `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓
@@ -0,0 +1,124 @@
# Bot Profile Manager — Design Spec
## Goal
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
## Features
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|---|---------|---------------|-----------------|---------------|---------------|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
## Architecture
```
resolveAndPlay() success / stop / clear / playNext exhausted
↓
BotInstance → BotProfileManager.onSongChange(song | null)
├─ updateAvatar(coverUrl | null) [fire-and-forget]
├─ updateDescription(song | null)
├─ updateNickname(song | null)
├─ updateAwayStatus(song | null)
├─ updateChannelDescription(song | null)
└─ sendNowPlayingMessage(song) [only when song != null]
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
BotInstance.disconnect() → (no action needed, server cleans up)
```
## File Changes
| File | Change |
|------|--------|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
## TS3Client Layer Extensions
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
```typescript
execCommand(cmd: string): Promise<void>
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
fileTransferInitUpload(channelID: bigint, path: string, password: string,
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
```
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
## BotProfileManager Detail
```typescript
interface ProfileConfig {
avatarEnabled: boolean; // default true
descriptionEnabled: boolean; // default true
nicknameEnabled: boolean; // default true
awayStatusEnabled: boolean; // default true
channelDescEnabled: boolean; // default true
nowPlayingMsgEnabled: boolean; // default true
}
```
### Permission Handling
- Each feature has an independent `permDenied: boolean` flag.
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
- On `onConnect()` → reset all flags (new connection may have different permissions).
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
### Nickname Truncation
- Format: `♪ {songInfo} | {defaultNickname}`
- TS3 max nickname: 30 characters
- If total > 30: truncate songInfo, keep defaultNickname
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
### Avatar Upload Flow (TS3)
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
3. `uploadFileData(host, info, Readable.from(buffer))`
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
### Fire-and-Forget
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
## Database Migration
```sql
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
```
## Web API
```
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
```
## Constraints
- All profile operations are async, never block playback
- Uses existing `axios` dependency for image download
- MD5 via Node.js built-in `crypto`
- No new npm dependencies required
@@ -0,0 +1,125 @@
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
Date: 2026-04-27
## Overview
Three features for the TeamSpeak Music Bot:
1. New `!artist <name>` command — loop playback filtered by artist
2. Fuzzy playlist name search in existing `!playlist` command
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
---
## Feature 1: `!artist` Command
### Behavior
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
### Flow
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
2. Call `provider.search(歌手名, 50)` to get up to 50 results
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
5. Clear current queue, add filtered songs, set mode to `Loop`
6. Play first song via `resolveAndPlay`
### Key Decisions
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
### Files Changed
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
- `src/bot/instance.ts`: New `cmdArtist()` method
---
## Feature 2: Playlist Fuzzy Search
### Behavior
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
### Flow
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
5. Merge results: public search results first (sorted by API relevance), then user matches
6. Take the first playlist, load its songs, play
### Key Decisions
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
### Files Changed
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
- `src/bot/commands.ts`: Update help text
---
## Feature 3: FM Bug Fix
### Root Cause Analysis
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
The most likely scenario for "no audio but status shows playing + song names changing":
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
3. ffmpeg exits quickly (clean exit or error)
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
5. `playNext()` advances to the next song
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
### Fix Strategy
**Fix 1 — FM auto-refill (primary fix):**
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
- Add a `refillFm()` method that fetches more FM songs and appends to queue
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
- Track FM state with a boolean flag `isFmMode` on the instance
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
- This prevents transient URL failures from accumulating toward the hard limit
**Fix 3 — FM refill before queue exhaustion:**
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
- If so, fire an async refill (don't block playback)
### Files Changed
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
---
## Implementation Order
1. **FM bug fix** first — it's a bug fix affecting current users
2. **Playlist fuzzy search** — small change, quick win
3. **Artist loop** — new feature, depends on queue/player being stable
---
## Testing
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
- Artist: Test with known artist names, test edge case (no results), test with platform flags
+294 -64
View File
@@ -1,91 +1,321 @@
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo ============================================
echo.
:: ============================================================
:: TSMusicBot Setup Script (Robust Edition)
:: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step
:: - Detailed logging to setup.log
:: - Skip already-completed steps on re-run
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: ---- Step 1: Check / install Node.js ----
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js not found. Attempting automatic installation...
echo.
:: Try winget first (available on Windows 10 1709+ and Windows 11)
where winget >nul 2>&1
if %errorlevel% equ 0 (
echo Installing Node.js via winget...
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
if %errorlevel% neq 0 (
echo winget installation failed. Please install Node.js manually from https://nodejs.org
pause
exit /b 1
)
:: Refresh PATH so node is available in this session
call refreshenv >nul 2>&1
:: If refreshenv is not available, ask user to restart
where node >nul 2>&1
if %errorlevel% neq 0 (
echo.
echo Node.js was installed but is not yet available in this terminal.
echo Please close this window and run setup.bat again.
pause
exit /b 0
)
) else (
echo winget is not available on this system.
echo Please install Node.js 20 LTS manually from https://nodejs.org
echo After installing, close this window and run setup.bat again.
pause
exit /b 1
)
) else (
echo [OK] Node.js found.
node --version
)
echo.
:: ---- Step 2: Install npm dependencies ----
echo Installing dependencies (this may take a few minutes)...
call npm install
if %errorlevel% neq 0 (
echo.
echo npm install failed. Check the error messages above.
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
echo [OK] Dependencies installed.
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ---- Step 3: Build the project ----
echo Building TypeScript project...
call npx tsc
if %errorlevel% neq 0 (
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/6" "Checking Node.js"
where node >nul 2>&1
if errorlevel 1 (
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
:: Check Node version >= 20
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
)
:: Robust version parse
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause
exit /b 1
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/6" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this.
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/6" "Checking network"
set "USE_MIRROR=0"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS)
where curl >nul 2>&1
if not errorlevel 1 (
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else (
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!"
set "USE_MIRROR=1"
)
) else (
:: Fallback to ping
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
)
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies
:: ============================================================
call :step "4/6" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
)
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
echo Press Ctrl+C to abort.
echo.
call npm install >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo.
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo.
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/6" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: ============================================================
call :step "6/6" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed."
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo.
echo Build failed. Check the error messages above.
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ---- Step 4: Create default config if missing ----
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo.
echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" (
echo Creating default config.json...
echo Please edit config.json with your TeamSpeak server details before starting the bot.
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ---- Done ----
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup complete!
echo Setup Complete!
echo ============================================
echo.
echo To start the bot, run: scripts\start.bat
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo.
echo Setup log saved to: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+113 -200
View File
@@ -5,11 +5,12 @@ import { accessSync, chmodSync, constants } from "node:fs";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Logger } from "../logger.js";
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
/** Ensure the given binary has execute permission. */
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
const globalActivePids = new Set<number>();
function isExecutable(binPath: string): boolean {
try {
accessSync(binPath, constants.X_OK);
@@ -25,7 +26,6 @@ function isExecutable(binPath: string): boolean {
}
}
/** Test if an ffmpeg binary actually works by running -version. */
function ffmpegWorks(bin: string): boolean {
try {
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
@@ -35,39 +35,16 @@ function ffmpegWorks(bin: string): boolean {
}
}
/**
* Resolved once at module load.
*
* Priority: system FFmpeg → bundled ffmpeg-static.
*
* System-installed FFmpeg is always compatible with the running OS/container,
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
* (passes `ffmpeg -version` but crashes during actual audio processing due to
* incompatible glibc or missing shared libraries).
*/
const resolvedFfmpeg: string = (() => {
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// 2. Fall back to bundled ffmpeg-static binary
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32";
if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
}
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg";
})();
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
@@ -93,14 +70,16 @@ export class AudioPlayer extends EventEmitter {
private nextFrameTime = 0;
private currentUrl = "";
private seekOffset = 0;
private framesPlayed = 0; // ground truth: number of 20ms frames sent
private framesPlayed = 0;
private sessionId = 0;
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
private static readonly BUFFER_LOW_WATER = 256 * 1024;
private ffmpegPaused = false;
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
private spawnFailed = false;
private consecutiveFailures = 0;
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
constructor(logger: Logger) {
super();
@@ -109,112 +88,127 @@ export class AudioPlayer extends EventEmitter {
}
play(url: string, seekSeconds = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop();
this.sessionId++;
const playSessionId = this.sessionId;
const currentSessionId = this.sessionId;
this.currentUrl = url;
this.seekOffset = seekSeconds;
this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
// Prevent rapid-fire spawn attempts when ffmpeg is broken
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error(
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
);
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
this.state = "idle";
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
this.emit("error", new Error("ffmpeg unavailable"));
return;
}
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
const args: string[] = [];
// BiliBili CDN requires Referer header for audio playback
if (url.includes("bilivideo") || url.includes("bilibili")) {
args.push(
"-headers",
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
);
args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n");
}
args.push(
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
);
if (seekSeconds > 0) {
args.push("-ss", String(seekSeconds));
}
args.push(
"-i", url,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
);
args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5");
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
const ffmpegBin = getFfmpegCommand();
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
}
// Prevent stream errors from crashing the process
this.ffmpeg.stdout!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stdout error");
});
this.ffmpeg.stderr!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stderr error");
});
let gotFirstData = false;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (!gotFirstData) {
gotFirstData = true;
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
// Backpressure: pause FFmpeg stdout when buffer is too large
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("close", (code, signal) => {
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
if (this.sessionId === playSessionId) {
this.ffmpeg = null; // Signal frame loop that no more data is coming
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
this.ffmpeg = null;
}
});
this.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) {
if (this.sessionId === currentSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Log FFmpeg stderr at info level for debugging playback issues
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
const msg = data.toString().trimEnd();
// Log important FFmpeg messages at info level
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
} else {
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
}
});
this.state = "playing";
this.startFrameLoop();
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
this.frameLoopRunning = false;
// 立即清空缓冲区,确保切歌瞬间静音 (
this.pcmBuffer = Buffer.alloc(0);
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
this.healthyFrames = 0;
}
private forceCleanup(proc: ChildProcess, pid: number): void {
if (!globalActivePids.has(pid)) return;
try {
proc.kill("SIGTERM");
} catch (e) { /* ignore */ }
const killTimeout = setTimeout(() => {
try {
process.kill(pid, 0);
process.kill(pid, "SIGKILL");
} catch (e) {
} finally {
globalActivePids.delete(pid);
}
}, 1500);
proc.unref();
proc.once("exit", () => {
clearTimeout(killTimeout);
globalActivePids.delete(pid);
});
}
private startFrameLoop(): void {
if (this.frameLoopRunning) return;
this.frameLoopRunning = true;
@@ -224,50 +218,37 @@ export class AudioPlayer extends EventEmitter {
private scheduleNextFrame(): void {
if (!this.frameLoopRunning) return;
const loopSessionId = this.sessionId;
this.nextFrameTime += FRAME_DURATION_MS;
const now = performance.now();
const delay = Math.max(0, this.nextFrameTime - now);
const delay = Math.max(0, this.nextFrameTime - performance.now());
setTimeout(() => {
// Discard callback from a stale play session
if (loopSessionId !== this.sessionId) return;
if (!this.frameLoopRunning) return;
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
if (this.state === "playing") {
this.sendNextFrame();
} else if (this.state === "paused") {
this.nextFrameTime = performance.now();
}
if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now();
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
if (this.spawnFailed) {
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
} else {
this.consecutiveFailures = 0; // Reset on successful track completion
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
}
return;
}
this.scheduleNextFrame();
}, delay);
}
private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
@@ -278,101 +259,33 @@ export class AudioPlayer extends EventEmitter {
const opusFrame = this.encoder.encode(adjusted);
this.emit("frame", opusFrame);
this.framesPlayed++;
if (this.framesPlayed === 1) {
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
}
// Log every ~10 seconds (500 frames * 20ms = 10s)
if (this.framesPlayed % 500 === 0) {
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
} catch (err) {
this.logger.error({ err }, "Error encoding/sending audio frame");
this.emit("error", err as Error);
}
}
private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm);
const factor = this.volume / 100;
const factor = (this.volume / 100) * 0.2;
const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i);
sample = Math.round(sample * factor);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
let sample = Math.round(pcm.readInt16LE(i) * factor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
/** Actual elapsed time in seconds (ground truth from frame count) */
getElapsed(): number {
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
}
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);
}
getSeekOffset(): number {
return this.seekOffset;
}
pause(): void {
if (this.state === "playing") {
this.state = "paused";
this.logger.debug("Playback paused");
}
}
resume(): void {
if (this.state === "paused") {
this.state = "playing";
this.nextFrameTime = performance.now();
this.logger.debug("Playback resumed");
}
}
stop(): void {
this.sessionId++;
this.frameLoopRunning = false;
if (this.ffmpeg) {
this.ffmpeg.kill("SIGTERM");
this.ffmpeg = null;
}
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
}
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol));
}
getVolume(): number {
return this.volume;
}
getState(): PlayerState {
return this.state;
}
}
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
resetFailures(): void { this.consecutiveFailures = 0; }
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
getVolume(): number { return this.volume; }
getState(): PlayerState { return this.state; }
}
+95
View File
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull();
});
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1"));
+27 -12
View File
@@ -20,6 +20,7 @@ export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
add(song: QueuedSong): void {
this.songs.push(song);
@@ -36,33 +37,39 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
// 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--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -81,12 +88,15 @@ export class PlayQueue {
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0];
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.songs.length);
} while (nextIndex === this.currentIndex && this.songs.length > 1);
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
@@ -114,6 +124,7 @@ export class PlayQueue {
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -141,6 +152,10 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
+1
View File
@@ -8,6 +8,7 @@ export interface ParsedCommand {
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
export const ADMIN_COMMANDS = new Set([
Regular → Executable
+191 -5
View File
@@ -13,8 +13,9 @@ import {
type ParsedCommand,
} from "./commands.js";
import type { Logger } from "../logger.js";
import type { BotDatabase } from "../data/database.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
export interface BotInstanceOptions {
id: string;
@@ -60,6 +61,10 @@ export class BotInstance extends EventEmitter {
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private profileManager: BotProfileManager;
private isFmMode = false;
constructor(options: BotInstanceOptions) {
super();
@@ -77,6 +82,14 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
const profileConfig = this.database.getProfileConfig(this.id);
this.profileManager = new BotProfileManager(
this.tsClient,
this.logger,
profileConfig,
options.tsOptions.nickname,
);
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -121,6 +134,10 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = true;
this.emit("disconnected");
});
this.tsClient.on("connected", () => {
this._startIdlePoller();
});
}
async connect(): Promise<void> {
@@ -134,10 +151,12 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
this.profileManager.onConnect();
this.emit("connected");
}
disconnect(): void {
this._cancelIdleTimer();
this.player.stop();
this.connected = false;
if (!this.disconnectEmitted) {
@@ -147,6 +166,48 @@ export class BotInstance extends EventEmitter {
this.tsClient.disconnect();
}
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
updateIdleTimeout(minutes: number): void {
this.config.idleTimeoutMinutes = minutes;
if (minutes === 0) this._cancelIdleTimer();
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
if (userCount <= 0) {
this._scheduleIdleCheck();
} else {
this._cancelIdleTimer();
}
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
setTimeout(poll, 30_000);
}
private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
if (!this.connected || minutes <= 0) return;
this.idleTimer = setTimeout(() => {
if (!this.connected) return;
this.logger.info({ idleMinutes: minutes }, "Channel empty, disconnecting due to idle timeout");
this.disconnect();
}, minutes * 60 * 1000);
}
private _cancelIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(
msg.message,
@@ -199,6 +260,7 @@ export class BotInstance extends EventEmitter {
"playlist",
"album",
"fm",
"artist",
]);
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
throw new Error("Bot is not connected to TeamSpeak");
@@ -238,6 +300,8 @@ export class BotInstance extends EventEmitter {
return this.cmdAlbum(cmd);
case "fm":
return this.cmdFm();
case "artist":
return this.cmdArtist(cmd);
case "vote":
return this.cmdVote(msg);
case "lyrics":
@@ -306,6 +370,10 @@ export class BotInstance extends EventEmitter {
platform: song.platform,
coverUrl: song.coverUrl,
});
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
this.emit("stateChange");
return true;
} catch (err) {
@@ -323,6 +391,7 @@ export class BotInstance extends EventEmitter {
const song = result.songs[0];
this.queue.clear();
this.isFmMode = false;
this.queue.add({ ...song, platform: provider.platform });
this.queue.play();
@@ -374,6 +443,10 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
this.emit("stateChange");
return "Stopped and queue cleared";
}
@@ -424,6 +497,10 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
this.emit("stateChange");
return "Queue cleared";
}
@@ -452,13 +529,48 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist ID or URL>";
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search
const id = this.extractId(cmd.args);
const songs = await provider.getPlaylistSongs(id);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let playlistId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use existing logic
playlistId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
let playlists = result.playlists ?? [];
// Also search user's personal playlists if logged in
if (provider.getUserPlaylists) {
try {
const userPlaylists = await provider.getUserPlaylists();
const query = cmd.args.toLowerCase();
const matched = userPlaylists.filter(
p => p.name.toLowerCase().includes(query)
);
// Merge: public results first (API-ranked), then user matches
playlists = [...playlists, ...matched];
} catch {
// User playlists unavailable — continue with public results
}
}
if (playlists.length === 0)
return `No playlists found for: ${cmd.args}`;
playlistId = playlists[0].id;
}
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -475,6 +587,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -496,12 +609,61 @@ export class BotInstance extends EventEmitter {
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.queue.setMode(PlayMode.RandomLoop);
this.isFmMode = true;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
if (result.songs.length === 0)
return `No results found for artist: ${cmd.args}`;
const query = cmd.args.toLowerCase();
let filtered = result.songs.filter(
s => s.artist.toLowerCase().includes(query)
);
// Fallback to unfiltered results if filtering drops everything
if (filtered.length === 0) {
filtered = result.songs.slice(0, 20);
}
this.queue.clear();
this.isFmMode = false;
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
}
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
}
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid);
@@ -559,9 +721,12 @@ export class BotInstance extends EventEmitter {
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <id> — Load playlist`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
`${p}album <id> — Load album`,
`${p}fm — Personal FM (NetEase)`,
`${p}artist <name> — Play songs by artist (loop)`,
`${p}artist -q <name> — Artist loop from QQ Music`,
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
`${p}now — Current song info`,
@@ -590,9 +755,26 @@ export class BotInstance extends EventEmitter {
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
// Proactive refill: when queue is running low, fetch more FM songs
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
} else {
this.player.stop();
// Defensive: only reached if play mode is changed away from RandomLoop during FM
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
await this.resolveAndPlay(refillNext);
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
this.emit("stateChange");
} finally {
@@ -639,6 +821,10 @@ export class BotInstance extends EventEmitter {
return this.connected;
}
getProfileManager(): BotProfileManager {
return this.profileManager;
}
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
+450
View File
@@ -0,0 +1,450 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import { HttpQueryError } from "../ts-protocol/http-query.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- Public API ---
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
const start = Date.now();
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info(
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
"Avatar updated",
);
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
/**
* Three-step upload. Each step is logged so the log can tell us whether
* a broken/loading avatar on the client is from:
* (a) init failing (no permission)
* (b) file transfer hanging on TCP 30033
* (c) client_flag_avatar not applying
* If (b) happens, the avatar MD5 would still be set in the past — leaving
* clients showing a placeholder. The flag is now only set after the TCP
* transfer resolves.
*/
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
// be misreported as success.
const result = await httpQuery.clientUpdate({ client_description: text });
this.logger.info({ status: result.status }, "Description updated");
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
this.logger.info("Description updated");
}
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*
* Values are collected as raw strings/numbers. The TS6 HTTP path
* forwards them as JSON (the server expects real spaces, not `\s`);
* the TS3 wire path escapes them on the fly. Previously the code
* escaped upfront and then split the escaped string to build the
* JSON body, so TS6 received literal backslashes and silently
* rejected the update.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
rawProps.client_nickname = this.defaultNickname;
} else {
const nickname = this.buildNickname(song);
if (nickname) {
rawProps.client_nickname = nickname;
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
rawProps.client_away = 0;
} else {
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
}
}
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
const result = await httpQuery.clientUpdate(rawProps);
this.logger.info(
{ status: result.status, props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
} else {
// TS3 wire protocol: escape string values inline.
// sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
const parts = Object.entries(rawProps).map(([k, v]) =>
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
);
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
this.logger.info(
{ props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
}
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
const status = err instanceof HttpQueryError ? err.status : undefined;
const body = err instanceof HttpQueryError ? err.body : undefined;
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
// - HTTP 401/403 → TS6 server rejects the API key/role
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
const isUnrecoverable =
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter") ||
status === 400 ||
status === 401 ||
status === 403;
if (isUnrecoverable) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, status, body, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, status, body, err }, "Profile update failed");
}
}
}
Regular → Executable
+12
View File
@@ -13,6 +13,15 @@ export interface BotConfig {
adminGroups: number[];
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
// Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000"
publicUrl: string;
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
// behind HTTPS-terminating proxies.
trustProxy: boolean;
}
export function getDefaultConfig(): BotConfig {
@@ -28,6 +37,9 @@ export function getDefaultConfig(): BotConfig {
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,
};
}
+77
View File
@@ -33,6 +33,24 @@ export interface BotInstance {
identity?: string;
}
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -40,6 +58,8 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
close(): void;
}
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
}
function initTables(db: Database.Database): void {
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
return {
db,
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0;
},
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
close() {
db.close();
},
Regular → Executable
+5 -1
View File
@@ -71,6 +71,7 @@ async function main() {
bilibiliProvider,
database: db,
config,
configPath: CONFIG_PATH,
logger,
cookieStore,
staticDir: STATIC_DIR,
@@ -78,7 +79,10 @@ async function main() {
await webServer.start();
logger.info({ webPort: config.webPort }, "TSMusicBot started");
logger.info(`WebUI: http://localhost:${config.webPort}`);
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
logger.info(
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
);
const shutdown = () => {
logger.info("Shutting down...");
+8 -1
View File
@@ -76,7 +76,14 @@ export function createApiServerManager(
);
} else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
const koaApp = qqModule.default ?? qqModule;
// The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app
// older: module itself may be the Koa app
const candidate = qqModule.default ?? qqModule;
const koaApp = typeof candidate.listen === "function"
? candidate
: candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
+80 -23
View File
@@ -10,6 +10,14 @@ import type {
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
export class QQMusicProvider implements MusicProvider {
readonly platform = "qq" as const;
private api: AxiosInstance;
@@ -36,33 +44,58 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/getSearchByKey", {
params: { key: query, pageSize: limit, ...this.cookieParams },
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
searchid: "1",
query,
num_per_page: Math.min(limit, 50),
},
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
(s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
})
);
const songs: Song[] = list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
return playUrl?.url || null;
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
} catch {
// try with songid
try {
const res = await this.api.get("/getMusicPlay", {
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return playUrl.url;
} catch {
// ignore
}
}
return null;
}
async getSongDetail(songId: string): Promise<Song | null> {
@@ -115,12 +148,14 @@ export class QQMusicProvider implements MusicProvider {
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
id: String(s.mid ?? s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
@@ -255,4 +290,26 @@ export class QQMusicProvider implements MusicProvider {
return { loggedIn: false };
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return [];
try {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
if (res.data?.response?.code !== 0) return [];
return (res.data?.response?.data?.playlists ?? []).map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.name ?? "",
coverUrl: p.imgurl ?? p.coverUrl ?? "",
songCount: p.song_count ?? p.listennum ?? 0,
platform: "qq",
}));
} catch {
return [];
}
}
}
+69
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
@@ -7,9 +8,11 @@ import {
listChannels,
listClients,
clientMove,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
import {
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
/** Escape a string for use in TS3 ServerQuery-style commands. */
export function escapeTS3(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/ /g, "\\s")
.replace(/\//g, "\\/")
.replace(/\|/g, "\\p")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
export interface TS3ClientOptions {
host: string;
@@ -172,6 +188,9 @@ export class TS3Client extends EventEmitter {
};
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
// Forward server password to the protocol library so it can be
// included in clientinit for password-protected servers
serverPassword: this.options.serverPassword,
logger: {
debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg),
@@ -278,6 +297,56 @@ export class TS3Client extends EventEmitter {
}
}
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.execCommand(cmd);
}
/** Fire a command without waiting for the server's response. */
async sendCommandNoWait(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.sendCommandNoWait(cmd);
}
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
if (!this.client) throw new Error("Not connected");
return this.client.execCommandWithResponse(cmd);
}
async fileTransferInitUpload(
channelID: bigint,
path: string,
password: string,
size: bigint,
overwrite = true,
): Promise<FileUploadInfo> {
if (!this.client) throw new Error("Not connected");
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
}
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.uploadFileData(host, info, data);
}
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
if (!this.client) throw new Error("Not connected");
await fileTransferDeleteFile(this.client, channelID, paths);
}
/** The server host (needed for file transfer TCP connections). */
getHost(): string {
return this.options.host;
}
/** The current channel ID of this client. */
getChannelId(): bigint {
if (!this.client) return 0n;
return this.client.channelID();
}
private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void {
+46 -2
View File
@@ -14,6 +14,38 @@ export interface HttpQueryResult {
body: unknown;
}
/**
* Thrown when the TS6 HTTP Query returns a non-2xx status.
*
* The previous implementation silently ignored the status code, so a 400
* (bad parameter) or 403 (insufficient permission) looked identical to
* success in logs. Callers that rely on the response being applied —
* nickname / description / away-status updates — should catch this and
* surface it rather than log "updated" for a request that was rejected.
*/
export class HttpQueryError extends Error {
readonly status: number;
readonly body: unknown;
readonly path: string;
constructor(path: string, status: number, body: unknown) {
const bodySnippet = (() => {
if (body == null) return "";
const s = typeof body === "string" ? body : JSON.stringify(body);
return s.length > 200 ? s.slice(0, 200) + "\u2026" : s;
})();
super(
`TS6 HTTP Query ${path} failed: status=${status}${
bodySnippet ? ` body=${bodySnippet}` : ""
}`,
);
this.name = "HttpQueryError";
this.status = status;
this.body = body;
this.path = path;
}
}
/**
* TS6 HTTP Query client.
*
@@ -157,12 +189,24 @@ export class TS6HttpQuery {
});
}
/** Update client properties (e.g., description) */
/**
* Update client properties (e.g., description, nickname, away).
*
* Throws HttpQueryError on non-2xx responses. The TS6 server returns
* 400 for invalid parameters and 403 for insufficient permissions;
* prior to this check the errors were silently dropped and callers
* logged a false "updated" success.
*/
async clientUpdate(
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
const path = `/1/clientupdate?sid=${sid}`;
const result = await this.request("POST", path, properties);
if (result.status < 200 || result.status >= 300) {
throw new HttpQueryError(path, result.status, result.body);
}
return result;
}
/** Move a client to a channel */
Regular → Executable
+23
View File
@@ -1,11 +1,13 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger
): Router {
const router = Router();
@@ -115,6 +117,27 @@ export function createBotRouter(
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/bot/settings — 读取全局 bot 行为设置
router.get("/settings", (_req, res) => {
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
});
// POST /api/bot/settings — 保存全局 bot 行为设置
router.post("/settings", (req, res) => {
const { idleTimeoutMinutes } = req.body;
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
config.idleTimeoutMinutes = idleTimeoutMinutes;
saveConfig(configPath, config);
// 通知所有 bot 实例更新定时器
for (const bot of botManager.getAllBots()) {
bot.updateIdleTimeout(idleTimeoutMinutes);
}
res.json({ ok: true });
});
return router;
}
+55 -15
View File
@@ -277,35 +277,54 @@ export function createPlayerRouter(
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-by-id", async (req, res) => {
router.post("/:botId/play-song", async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
res.json({ message: "Song not found" });
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
queue.clear();
queue.add({ ...song, platform: provider.platform });
queue.add(song);
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
res.json({ message: `Cannot play: ${song.name || song.id}` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/add-song", async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
queue.playAt(queue.size() - 1);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return;
}
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
@@ -343,6 +362,27 @@ export function createPlayerRouter(
}
});
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => {
if (!database) {
res.json({ history: [] });
Regular → Executable
+12 -1
View File
@@ -22,6 +22,7 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
logger: Logger;
cookieStore?: CookieStore;
staticDir?: string;
@@ -37,11 +38,21 @@ export function createWebServer(options: WebServerOptions): WebServer {
const server = http.createServer(app);
const logger = options.logger.child({ component: "web" });
if (options.config.trustProxy) {
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
app.set("trust proxy", true);
}
app.use(express.json());
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
app.use(
"/api/bot",
createBotRouter(options.botManager, options.config, logger)
createBotRouter(options.botManager, options.config, options.configPath, logger)
);
app.use(
"/api/music",
-57
View File
@@ -797,9 +797,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -821,9 +818,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -845,9 +839,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -869,9 +860,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -893,9 +881,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -917,9 +902,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1088,9 +1070,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1105,9 +1084,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1122,9 +1098,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1139,9 +1112,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1156,9 +1126,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1173,9 +1140,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1190,9 +1154,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1207,9 +1168,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1224,9 +1182,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1241,9 +1196,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1258,9 +1210,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1275,9 +1224,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1292,9 +1238,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+185 -6
View File
@@ -59,10 +59,30 @@
</RouterLink>
</div>
</nav>
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
<div class="link-dialog" @click.stop>
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
<input
ref="linkInputRef"
class="link-dialog-input"
:value="linkDialog.url"
readonly
@focus="($event.target as HTMLInputElement).select()"
/>
<div class="link-dialog-actions">
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
{{ linkDialog.copied ? '已复制' : '复制链接' }}
</button>
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
@@ -71,17 +91,92 @@ const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
const linkInputRef = ref<HTMLInputElement | null>(null);
const publicBaseUrl = ref<string | null>(null);
const linkDialog = reactive({
open: false,
url: '',
name: '',
copied: false,
});
function selectBot(id: string) {
store.setActiveBotId(id);
dropdownOpen.value = false;
}
function copyBotLink(id: string) {
const url = `${window.location.origin}/bot/${id}`;
navigator.clipboard.writeText(url).then(() => {
dropdownOpen.value = false;
});
function resolveBaseUrl(): string {
const base = publicBaseUrl.value;
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
return window.location.origin;
}
async function tryClipboardWrite(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
async function copyBotLink(id: string) {
const bot = store.bots.find((b) => b.id === id);
const url = `${resolveBaseUrl()}/bot/${id}`;
linkDialog.url = url;
linkDialog.name = bot?.name ?? '机器人';
linkDialog.copied = false;
linkDialog.open = true;
dropdownOpen.value = false;
// Try to copy silently; user can still manually select if it fails.
const ok = await tryClipboardWrite(url);
if (ok) linkDialog.copied = true;
await nextTick();
linkInputRef.value?.focus();
linkInputRef.value?.select();
}
async function copyLinkFromDialog() {
const ok = await tryClipboardWrite(linkDialog.url);
if (ok) {
linkDialog.copied = true;
} else {
linkInputRef.value?.focus();
linkInputRef.value?.select();
}
}
function closeLinkDialog() {
linkDialog.open = false;
}
async function loadPublicBaseUrl() {
try {
const res = await fetch('/api/config/public-url');
if (!res.ok) return;
const data = (await res.json()) as { publicUrl?: string | null };
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
} catch {
// ignore — fall back to window.location.origin
}
}
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
@@ -108,6 +203,7 @@ function onClickOutside(e: MouseEvent) {
onMounted(() => {
document.addEventListener('click', onClickOutside);
loadPublicBaseUrl();
});
onUnmounted(() => {
@@ -376,4 +472,87 @@ onUnmounted(() => {
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
.link-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.link-dialog {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
min-width: 360px;
max-width: 90vw;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.link-dialog-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 6px;
}
.link-dialog-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.link-dialog-input {
width: 100%;
padding: 10px 12px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: inherit;
user-select: all;
-webkit-user-select: all;
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
.link-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.link-dialog-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--hover-bg);
color: inherit;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
&:hover {
background: var(--bg-card);
}
&.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
&:hover {
filter: brightness(1.08);
}
}
}
</style>
+25
View File
@@ -3,6 +3,14 @@
<div class="queue-header">
<h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span>
<button
v-if="botQueue.length > 0"
class="clear-btn"
@click="clearAndStop"
title="清空队列并停止播放"
>
<Icon icon="mdi:stop-circle-outline" />
</button>
<button class="close-btn" @click="$emit('close')">
<Icon icon="mdi:close" />
</button>
@@ -70,6 +78,15 @@ async function removeSong(index: number) {
// Ignore
}
}
async function clearAndStop() {
try {
await store.stop();
await store.fetchQueue();
} catch {
// Ignore
}
}
</script>
<style lang="scss" scoped>
@@ -117,6 +134,14 @@ async function removeSong(index: number) {
&:hover { opacity: 1; }
}
.clear-btn {
font-size: 18px;
opacity: 0.6;
transition: opacity var(--transition-fast);
color: var(--text-primary);
&:hover { opacity: 1; }
}
.queue-empty {
padding: 40px 20px;
text-align: center;
+2 -1
View File
@@ -28,9 +28,10 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js';
defineProps<{
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
song: Song;
index: number;
active?: boolean;
}>();
+13 -1
View File
@@ -8,7 +8,7 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
}
export interface BotStatus {
@@ -259,6 +259,13 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
async playSong(song: Song) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async addToQueue(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
@@ -269,6 +276,11 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
+5 -3
View File
@@ -19,8 +19,8 @@
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.play(song.name, song.platform)"
@add="store.addToQueue(song.name, song.platform)"
@play="store.playSong(song)"
@add="store.addSong(song)"
/>
</div>
</div>
@@ -35,7 +35,9 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
const history = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
import { Song } from '../stores/player.js';
const history = ref<Song[]>([]);
const loading = ref(true);
onMounted(async () => {
+2 -2
View File
@@ -41,7 +41,7 @@
v-for="song in store.dailySongs.slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playById(song.id, song.platform)"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
@@ -105,7 +105,7 @@
v-for="song in store.bilibiliPopular.slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playById(song.id, song.platform)"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
+5 -13
View File
@@ -31,8 +31,8 @@
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)"
@add="store.addToQueueById(song.id, song.platform)"
@play="store.playSong(song)"
@add="store.addSong(song)"
/>
</div>
</template>
@@ -53,6 +53,8 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
const route = useRoute();
import { Song } from '../stores/player.js';
interface PlaylistDetail {
id: string;
name: string;
@@ -61,18 +63,8 @@ interface PlaylistDetail {
songCount: number;
}
interface SongItem {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: string;
}
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<SongItem[]>([]);
const songs = ref<Song[]>([]);
const loading = ref(true);
async function playAll() {
+5 -3
View File
@@ -27,8 +27,8 @@
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)"
@add="store.addToQueueById(song.id, song.platform)"
@play="store.playSong(song)"
@add="store.addSong(song)"
/>
</div>
@@ -50,7 +50,9 @@ const store = usePlayerStore();
const route = useRoute();
const query = ref((route.query.q as string) || '');
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
import { Song } from '../stores/player.js';
const results = ref<Song[]>([]);
const loading = ref(false);
const searched = ref(false);
Regular → Executable
+43
View File
@@ -371,6 +371,32 @@
</div>
</div>
</section>
<!-- Idle Timeout -->
<section class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
<div>
<div>闲置自动退出</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
</div>
</div>
<div class="prefix-input-wrap">
<input
v-model.number="idleTimeout"
type="number"
min="0"
class="input input-sm"
style="max-width:80px"
placeholder="0"
/>
<span style="font-size:13px; opacity:0.7">分钟</span>
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
</section>
</div>
</template>
@@ -661,10 +687,27 @@ async function savePrefix() {
// Prefix is saved client-side for now
}
// Idle timeout
const idleTimeout = ref(0);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
} catch { /* ignore */ }
}
async function saveIdleTimeout() {
try {
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
} catch { /* ignore */ }
}
onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus();
loadQuality();
loadIdleTimeout();
});
onUnmounted(() => {