mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #43 from FFatTiger/feature/fm-artist-playlist
feat: 新增 !artist 歌手循环播放、!playlist 歌单模糊搜索,修复 !fm 播放中断,QQ 音乐个人歌单支持
This commit is contained in:
8 files changed
+835
-22
No files matched your search
@@ -6,5 +6,6 @@ dist/
|
|||||||
config.json
|
config.json
|
||||||
cookies/
|
cookies/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
/bin/
|
/bin/
|
||||||
scripts/navbar_bigger.png
|
scripts/navbar_bigger.png
|
||||||
@@ -254,9 +254,11 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!vol <0-100>` | 设置音量 |
|
| `!vol <0-100>` | 设置音量 |
|
||||||
| `!queue` | 查看播放队列 |
|
| `!queue` | 查看播放队列 |
|
||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <ID>` | 加载歌单 |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||||
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
| `!album <ID>` | 加载专辑 |
|
| `!album <ID>` | 加载专辑 |
|
||||||
| `!fm` | 私人 FM(网易云) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
||||||
|
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||||
| `!lyrics` | 显示当前歌词 |
|
| `!lyrics` | 显示当前歌词 |
|
||||||
| `!now` | 当前播放信息 |
|
| `!now` | 当前播放信息 |
|
||||||
| `!vote` | 投票跳过当前歌曲 |
|
| `!vote` | 投票跳过当前歌曲 |
|
||||||
@@ -485,6 +487,13 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
|||||||
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
||||||
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
|
- **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 单一代码路径。
|
- **升级 `@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
|
||||||
@@ -78,6 +78,8 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private spawnFailed = false;
|
private spawnFailed = false;
|
||||||
private consecutiveFailures = 0;
|
private consecutiveFailures = 0;
|
||||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
|
private healthyFrames = 0;
|
||||||
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -93,6 +95,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
this.seekOffset = seekSeconds;
|
this.seekOffset = seekSeconds;
|
||||||
this.framesPlayed = 0;
|
this.framesPlayed = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
this.spawnFailed = false;
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.currentUrl = "";
|
this.currentUrl = "";
|
||||||
this.seekOffset = 0;
|
this.seekOffset = 0;
|
||||||
this.framesPlayed = 0;
|
this.framesPlayed = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private forceCleanup(proc: ChildProcess, pid: number): void {
|
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||||
@@ -255,6 +259,11 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
const opusFrame = this.encoder.encode(adjusted);
|
const opusFrame = this.encoder.encode(adjusted);
|
||||||
this.emit("frame", opusFrame);
|
this.emit("frame", opusFrame);
|
||||||
this.framesPlayed++;
|
this.framesPlayed++;
|
||||||
|
this.healthyFrames++;
|
||||||
|
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.emit("error", err as Error);
|
this.emit("error", err as Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface ParsedCommand {
|
|||||||
export const PUBLIC_COMMANDS = new Set([
|
export const PUBLIC_COMMANDS = new Set([
|
||||||
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
||||||
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
||||||
|
"artist",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ADMIN_COMMANDS = new Set([
|
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 idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private channelUserCount = 0;
|
private channelUserCount = 0;
|
||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
|
private isFmMode = false;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -259,6 +260,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
"playlist",
|
"playlist",
|
||||||
"album",
|
"album",
|
||||||
"fm",
|
"fm",
|
||||||
|
"artist",
|
||||||
]);
|
]);
|
||||||
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
|
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
|
||||||
throw new Error("Bot is not connected to TeamSpeak");
|
throw new Error("Bot is not connected to TeamSpeak");
|
||||||
@@ -298,6 +300,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.cmdAlbum(cmd);
|
return this.cmdAlbum(cmd);
|
||||||
case "fm":
|
case "fm":
|
||||||
return this.cmdFm();
|
return this.cmdFm();
|
||||||
|
case "artist":
|
||||||
|
return this.cmdArtist(cmd);
|
||||||
case "vote":
|
case "vote":
|
||||||
return this.cmdVote(msg);
|
return this.cmdVote(msg);
|
||||||
case "lyrics":
|
case "lyrics":
|
||||||
@@ -387,6 +391,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
@@ -438,6 +443,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||||
});
|
});
|
||||||
@@ -491,6 +497,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
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> {
|
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);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
|
||||||
|
// Determine if input is a numeric ID or a name search
|
||||||
const id = this.extractId(cmd.args);
|
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";
|
if (songs.length === 0) return "Playlist is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
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";
|
if (songs.length === 0) return "Album is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -566,12 +609,61 @@ export class BotInstance extends EventEmitter {
|
|||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
}
|
}
|
||||||
|
this.queue.setMode(PlayMode.RandomLoop);
|
||||||
|
this.isFmMode = true;
|
||||||
|
this.player.resetFailures();
|
||||||
|
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
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> {
|
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
|
||||||
if (!msg) return "Vote can only be used in TeamSpeak";
|
if (!msg) return "Vote can only be used in TeamSpeak";
|
||||||
this.voteSkipUsers.add(msg.invokerUid);
|
this.voteSkipUsers.add(msg.invokerUid);
|
||||||
@@ -629,9 +721,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}vol <0-100> — Set volume`,
|
`${p}vol <0-100> — Set volume`,
|
||||||
`${p}queue — Show queue`,
|
`${p}queue — Show queue`,
|
||||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
`${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}album <id> — Load album`,
|
||||||
`${p}fm — Personal FM (NetEase)`,
|
`${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}vote — Vote to skip`,
|
||||||
`${p}lyrics — Show lyrics`,
|
`${p}lyrics — Show lyrics`,
|
||||||
`${p}now — Current song info`,
|
`${p}now — Current song info`,
|
||||||
@@ -661,10 +756,25 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (!started) {
|
if (!started) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.profileManager.onSongChange(null).catch(() => {});
|
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 {
|
} else {
|
||||||
this.player.stop();
|
// Defensive: only reached if play mode is changed away from RandomLoop during FM
|
||||||
this.profileManager.onSongChange(null).catch(() => {});
|
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");
|
this.emit("stateChange");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+56
-15
@@ -10,6 +10,14 @@ import type {
|
|||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { parseLyrics } from "./netease.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 {
|
export class QQMusicProvider implements MusicProvider {
|
||||||
readonly platform = "qq" as const;
|
readonly platform = "qq" as const;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -36,23 +44,34 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
const res = await this.api.get("/getSearchByKey", {
|
const reqData = JSON.stringify({
|
||||||
params: { key: query, pageSize: limit, ...this.cookieParams },
|
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(
|
const songs: Song[] = list.map((s: any) => ({
|
||||||
(s: any) => ({
|
id: String(s.mid ?? s.id),
|
||||||
id: String(s.mid ?? s.songmid ?? s.songid),
|
name: s.title ?? s.name ?? "",
|
||||||
name: s.songname ?? "",
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
album: s.album?.name ?? s.album?.title ?? "",
|
||||||
album: s.albumname ?? "",
|
duration: s.interval ?? 0,
|
||||||
duration: s.interval ?? 0,
|
coverUrl: s.album?.mid
|
||||||
coverUrl: s.albummid
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
: "",
|
||||||
: "",
|
platform: "qq",
|
||||||
platform: "qq",
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
}
|
}
|
||||||
@@ -271,4 +290,26 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return { loggedIn: false };
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user