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() ✓