15 KiB
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:
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:
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:
this.healthyFrames = 0;
In stop(), after this.framesPlayed = 0; (line ~181), add:
this.healthyFrames = 0;
- Step 4: Commit
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:
private isFmMode = false;
- Step 2: Add refillFm method
In src/bot/instance.ts, before the cmdVote method (after cmdFm's closing brace), add:
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:
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:
} 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:
// 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:
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:
this.isFmMode = false;
In cmdClear() (same line), add:
this.isFmMode = false;
In cmdPlay(), after this.queue.clear();, add:
this.isFmMode = false;
In cmdPlaylist(), after this.queue.clear();, add:
this.isFmMode = false;
In cmdAlbum(), after this.queue.clear();, add:
this.isFmMode = false;
- Step 6: Commit
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:
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
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
artistin PUBLIC_COMMANDS
In src/bot/commands.ts, line 9, add "artist" to the PUBLIC_COMMANDS set:
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
artistto the AUDIO_COMMANDS set in executeCommand
In src/bot/instance.ts, in the executeCommand method, add "artist" to the AUDIO_COMMANDS set (line ~253):
const AUDIO_COMMANDS = new Set([
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
"artist",
]);
- Step 3: Add
artistcase to the switch in executeCommand
In src/bot/instance.ts, after the case "fm": block (line ~300), add:
case "artist":
return this.cmdArtist(cmd);
- Step 4: Implement cmdArtist method
Add the cmdArtist method in src/bot/instance.ts, after cmdFm():
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
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
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
Expected: No errors.
- Step 2: Verify command parsing
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
git add -A && git commit -m "chore: type fixes from final verification"
(Only if there were issues)
Self-Review Checklist
-
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
-
No placeholders — all steps have exact code.
-
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() ✓