mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-03 05:22:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ecd92d7c | ||
|
|
f41cd888f9 | ||
|
|
4de8ac1810 | ||
|
|
f9e2a210b7 | ||
|
|
62fb67933d | ||
|
|
43f4dd94aa | ||
|
|
1a5bd74357 | ||
|
|
31e5e08def | ||
|
|
5d74979af3 | ||
|
|
24da8cfc2e | ||
|
|
e3b4e1634d | ||
|
|
2f186ec002 | ||
|
|
08170a2574 | ||
|
|
7eb8477dad | ||
|
|
2fb0cd2489 | ||
|
|
8dcdf01129 |
No files matched your search
@@ -6,5 +6,6 @@ dist/
|
||||
config.json
|
||||
cookies/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
/bin/
|
||||
scripts/navbar_bigger.png
|
||||
@@ -254,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` | 投票跳过当前歌曲 |
|
||||
@@ -485,6 +487,13 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
||||
- **竞争条件防护**: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,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
@@ -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
|
||||
@@ -78,6 +78,8 @@ export class AudioPlayer extends EventEmitter {
|
||||
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();
|
||||
@@ -93,6 +95,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.currentUrl = url;
|
||||
this.seekOffset = seekSeconds;
|
||||
this.framesPlayed = 0;
|
||||
this.healthyFrames = 0;
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
|
||||
@@ -179,6 +182,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.currentUrl = "";
|
||||
this.seekOffset = 0;
|
||||
this.framesPlayed = 0;
|
||||
this.healthyFrames = 0;
|
||||
}
|
||||
|
||||
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||
@@ -255,6 +259,11 @@ export class AudioPlayer extends EventEmitter {
|
||||
const opusFrame = this.encoder.encode(adjusted);
|
||||
this.emit("frame", opusFrame);
|
||||
this.framesPlayed++;
|
||||
this.healthyFrames++;
|
||||
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.healthyFrames = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit("error", err as Error);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
+115
-5
@@ -64,6 +64,7 @@ export class BotInstance extends EventEmitter {
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private channelUserCount = 0;
|
||||
private profileManager: BotProfileManager;
|
||||
private isFmMode = false;
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -259,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");
|
||||
@@ -298,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":
|
||||
@@ -387,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();
|
||||
|
||||
@@ -438,6 +443,7 @@ 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");
|
||||
});
|
||||
@@ -491,6 +497,7 @@ 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");
|
||||
});
|
||||
@@ -522,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 });
|
||||
}
|
||||
@@ -545,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 });
|
||||
}
|
||||
@@ -566,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);
|
||||
@@ -629,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`,
|
||||
@@ -661,10 +756,25 @@ 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();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
// 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 {
|
||||
|
||||
+56
-15
@@ -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,23 +44,34 @@ 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.mid ?? 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: [] };
|
||||
}
|
||||
@@ -271,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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user