From 7eb8477dadf898c1a23e418bdf5674a8dcd06b0c Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 16:55:35 +0800 Subject: [PATCH 01/12] docs: add design spec for FM bug fix, !artist command, and playlist fuzzy search --- .../2026-04-27-fm-artist-playlist-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-fm-artist-playlist-design.md diff --git a/docs/superpowers/specs/2026-04-27-fm-artist-playlist-design.md b/docs/superpowers/specs/2026-04-27-fm-artist-playlist-design.md new file mode 100644 index 0000000..df77c49 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-fm-artist-playlist-design.md @@ -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 ` 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 ` 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 From 08170a25740ba3b93f832e72f7c37de0e08b44de Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:01:03 +0800 Subject: [PATCH 02/12] docs: add implementation plan for FM fix, !artist, and playlist search --- .../plans/2026-04-27-fm-artist-playlist.md | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-fm-artist-playlist.md diff --git a/docs/superpowers/plans/2026-04-27-fm-artist-playlist.md b/docs/superpowers/plans/2026-04-27-fm-artist-playlist.md new file mode 100644 index 0000000..432d9fa --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-fm-artist-playlist.md @@ -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 { + 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 { + 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 { + 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 { + if (!cmd.args) return "Usage: !playlist "; + 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 — Load playlist` +``` + +to: + +``` +`${p}playlist — 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 { + if (!cmd.args) return "Usage: !artist "; + 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 — 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` — called from cmdFm (indirectly via playNext trigger), playNext ✓ + - `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓ From 2f186ec0022ab3a19610af2f96daa70bac38e75b Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:04:26 +0800 Subject: [PATCH 03/12] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7a588c1..6ed86bf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ dist/ config.json cookies/ .superpowers/ +.worktrees/ /bin/ scripts/navbar_bigger.png From e3b4e1634d1ad8c4393a756755ae50e810beeb94 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:09:49 +0800 Subject: [PATCH 04/12] fix(player): reset consecutiveFailures after sustained healthy playback --- src/audio/player.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/audio/player.ts b/src/audio/player.ts index e62232b..aeacecb 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -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); } From 24da8cfc2e3a036b91823e06d590d6f7e17c20f3 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:20:34 +0800 Subject: [PATCH 05/12] fix: FM auto-refill to prevent audio dropout after initial batch --- src/bot/instance.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 1bc61b7..9c231fc 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -64,6 +64,7 @@ export class BotInstance extends EventEmitter { private idleTimer: ReturnType | null = null; private channelUserCount = 0; private profileManager: BotProfileManager; + private isFmMode = false; constructor(options: BotInstanceOptions) { super(); @@ -387,6 +388,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 +440,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 +494,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"); }); @@ -529,6 +533,7 @@ export class BotInstance extends EventEmitter { 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 +550,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 +572,30 @@ 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 refillFm(): Promise { + 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 { if (!msg) return "Vote can only be used in TeamSpeak"; this.voteSkipUsers.add(msg.invokerUid); @@ -661,10 +685,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(() => {}); + // 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 { From 5d74979af3e0b11e47484b63851a974dd2ba4456 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:29:00 +0800 Subject: [PATCH 06/12] docs: clarify exhausted-queue refill is defensive-only in FM RandomLoop mode --- src/bot/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 9c231fc..cd7ce24 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -690,7 +690,7 @@ export class BotInstance extends EventEmitter { this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed")); } } else { - // Queue exhausted — in FM mode, refill instead of stopping + // Defensive: only reached if play mode is changed away from RandomLoop during FM if (this.isFmMode) { await this.refillFm(); const refillNext = this.queue.next(); From 31e5e08def6edee739fe2af6f699bde2de49cd4f Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:31:50 +0800 Subject: [PATCH 07/12] feat: support playlist name fuzzy search in !playlist command --- src/bot/instance.ts | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index cd7ce24..47a6a41 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -526,10 +526,44 @@ export class BotInstance extends EventEmitter { } private async cmdPlaylist(cmd: ParsedCommand): Promise { - if (!cmd.args) return "Usage: !playlist "; + if (!cmd.args) return "Usage: !playlist "; 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(); @@ -653,7 +687,7 @@ export class BotInstance extends EventEmitter { `${p}vol <0-100> — Set volume`, `${p}queue — Show queue`, `${p}mode — Play mode`, - `${p}playlist — Load playlist`, + `${p}playlist — Load playlist by name or ID`, `${p}album — Load album`, `${p}fm — Personal FM (NetEase)`, `${p}vote — Vote to skip`, From 1a5bd74357d7c1f67836e3fe19481dcd50fbb84c Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 17:40:35 +0800 Subject: [PATCH 08/12] feat: add !artist command for artist-based loop playback --- src/bot/commands.ts | 1 + src/bot/instance.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/bot/commands.ts b/src/bot/commands.ts index fcd66ec..bf40ddc 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -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([ diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 47a6a41..89a5940 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -260,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"); @@ -299,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": @@ -616,6 +619,37 @@ export class BotInstance extends EventEmitter { return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; } + private async cmdArtist(cmd: ParsedCommand): Promise { + if (!cmd.args) return "Usage: !artist "; + 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 { if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return; try { @@ -690,6 +724,7 @@ export class BotInstance extends EventEmitter { `${p}playlist — Load playlist by name or ID`, `${p}album — Load album`, `${p}fm — Personal FM (NetEase)`, + `${p}artist — Play songs by artist (loop)`, `${p}vote — Vote to skip`, `${p}lyrics — Show lyrics`, `${p}now — Current song info`, From 43f4dd94aab97a5ff4bd9be43e37b01ebf16a2b8 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 18:08:09 +0800 Subject: [PATCH 09/12] feat(qq): add getUserPlaylists support for QQ Music provider --- src/music/qq.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/music/qq.ts b/src/music/qq.ts index 0049ea2..0851a28 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -271,4 +271,26 @@ export class QQMusicProvider implements MusicProvider { return { loggedIn: false }; } } + + async getUserPlaylists(): Promise { + 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 []; + } + } } From 62fb67933daf5410d8c3688bdd7ad63e0027b2e2 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 18:14:27 +0800 Subject: [PATCH 10/12] docs: update README with new !artist, !playlist, FM fix, and QQ playlist features --- src/bot/instance.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 89a5940..22314b8 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -722,9 +722,11 @@ export class BotInstance extends EventEmitter { `${p}queue — Show queue`, `${p}mode — Play mode`, `${p}playlist — Load playlist by name or ID`, + `${p}playlist -q — Load playlist from QQ Music`, `${p}album — Load album`, `${p}fm — Personal FM (NetEase)`, `${p}artist — Play songs by artist (loop)`, + `${p}artist -q — Artist loop from QQ Music`, `${p}vote — Vote to skip`, `${p}lyrics — Show lyrics`, `${p}now — Current song info`, From f9e2a210b7cedbd9b02e4ff8a70068a9f9f02496 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 18:15:36 +0800 Subject: [PATCH 11/12] docs: update README with new !artist, !playlist, FM fix, and QQ playlist features --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb22110..2533110 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,11 @@ sudo systemctl start tsmusicbot | `!vol <0-100>` | 设置音量 | | `!queue` | 查看播放队列 | | `!mode ` | 切换播放模式 | -| `!playlist ` | 加载歌单 | +| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) | +| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!album ` | 加载专辑 | -| `!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 单一代码路径。 From 4de8ac1810cfdc1aa2dddad605b1ce8d338dbf74 Mon Sep 17 00:00:00 2001 From: fattiger <1106842466@qq.com> Date: Mon, 27 Apr 2026 19:04:31 +0800 Subject: [PATCH 12/12] fix(qq): bypass broken c.y.qq.com search API, use u.y.qq.com directly --- src/music/qq.ts | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/music/qq.ts b/src/music/qq.ts index 0851a28..191dc8a 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -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 { - 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: [] }; }