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 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 单一代码路径。 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() ✓ 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 diff --git a/scripts/setup.bat b/scripts/setup.bat index 4ef4a1d..b3884ba 100644 --- a/scripts/setup.bat +++ b/scripts/setup.bat @@ -1,91 +1,321 @@ @echo off +setlocal enabledelayedexpansion +chcp 65001 >nul title TSMusicBot Setup -echo ============================================ -echo TSMusicBot - First-Time Setup (Windows) -echo ============================================ -echo. + +:: ============================================================ +:: TSMusicBot Setup Script (Robust Edition) +:: - Auto-detect China network, switch to npmmirror +:: - Strict error checking at every step +:: - Detailed logging to setup.log +:: - Skip already-completed steps on re-run +:: ============================================================ + +set "SCRIPT_VERSION=2.0" +set "MIN_NODE_MAJOR=20" +set "LOG_FILE=%~dp0..\setup.log" +set "FAILED=0" :: Resolve project root (one level up from scripts/) -cd /d "%~dp0.." - -:: ---- Step 1: Check / install Node.js ---- -where node >nul 2>&1 -if %errorlevel% neq 0 ( - echo Node.js not found. Attempting automatic installation... - echo. - - :: Try winget first (available on Windows 10 1709+ and Windows 11) - where winget >nul 2>&1 - if %errorlevel% equ 0 ( - echo Installing Node.js via winget... - winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements - if %errorlevel% neq 0 ( - echo winget installation failed. Please install Node.js manually from https://nodejs.org - pause - exit /b 1 - ) - :: Refresh PATH so node is available in this session - call refreshenv >nul 2>&1 - :: If refreshenv is not available, ask user to restart - where node >nul 2>&1 - if %errorlevel% neq 0 ( - echo. - echo Node.js was installed but is not yet available in this terminal. - echo Please close this window and run setup.bat again. - pause - exit /b 0 - ) - ) else ( - echo winget is not available on this system. - echo Please install Node.js 20 LTS manually from https://nodejs.org - echo After installing, close this window and run setup.bat again. - pause - exit /b 1 - ) -) else ( - echo [OK] Node.js found. - node --version -) -echo. - -:: ---- Step 2: Install npm dependencies ---- -echo Installing dependencies (this may take a few minutes)... -call npm install -if %errorlevel% neq 0 ( - echo. - echo npm install failed. Check the error messages above. +cd /d "%~dp0.." || ( + echo [FATAL] Cannot change to project directory. pause exit /b 1 ) -echo [OK] Dependencies installed. + +set "PROJECT_ROOT=%cd%" + +:: ---- Initialize log ---- +echo. > "%LOG_FILE%" +call :log "============================================" +call :log " TSMusicBot Setup v%SCRIPT_VERSION%" +call :log " Started: %date% %time%" +call :log " Project root: %PROJECT_ROOT%" +call :log "============================================" + +echo ============================================ +echo TSMusicBot - First-Time Setup (Windows) +echo Version %SCRIPT_VERSION% +echo ============================================ +echo. +echo Log file: %LOG_FILE% echo. -:: ---- Step 3: Build the project ---- -echo Building TypeScript project... -call npx tsc -if %errorlevel% neq 0 ( +:: ============================================================ +:: Step 1: Check Node.js +:: ============================================================ +call :step "1/6" "Checking Node.js" + +where node >nul 2>&1 +if errorlevel 1 ( + call :error "Node.js not found in PATH." + echo. + echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of: + echo - https://nodejs.org/ ^(official^) + echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^) + echo. + echo After installation: + echo 1. Close this window completely + echo 2. Open a NEW Command Prompt + echo 3. Run scripts\setup.bat again + echo. + pause + exit /b 1 +) + +:: Check Node version >= 20 +for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a" +for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do ( + for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b" +) + +:: Robust version parse +for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v" +set "NODE_VER_NUM=%NODE_VER:v=%" +for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a" + +call :log "Node.js version: %NODE_VER%" +echo [OK] Node.js found: %NODE_VER% + +if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% ( + call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%." + echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer. + pause + exit /b 1 +) +echo. + +:: ============================================================ +:: Step 2: Check npm +:: ============================================================ +call :step "2/6" "Checking npm" + +where npm >nul 2>&1 +if errorlevel 1 ( + call :error "npm not found. This is unusual since Node.js is installed." + echo Please reinstall Node.js to fix this. + pause + exit /b 1 +) + +for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v" +call :log "npm version: %NPM_VER%" +echo [OK] npm found: %NPM_VER% +echo. + +:: ============================================================ +:: Step 3: Detect network and configure mirror +:: ============================================================ +call :step "3/6" "Checking network" + +set "USE_MIRROR=0" + +:: Try reaching npm registry with a short timeout +echo Testing connection to registry.npmjs.org... +call :log "Testing npm registry connectivity..." + +:: Use curl if available (more reliable than ping for HTTPS) +where curl >nul 2>&1 +if not errorlevel 1 ( + curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul + set /p HTTP_CODE=<"%TEMP%\npmtest.txt" + del "%TEMP%\npmtest.txt" >nul 2>&1 + if "!HTTP_CODE!"=="200" ( + echo [OK] npm registry reachable. + call :log "npm registry HTTP 200 OK" + ) else ( + echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^). + call :log "npm registry returned: !HTTP_CODE!" + set "USE_MIRROR=1" + ) +) else ( + :: Fallback to ping + ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1 + if errorlevel 1 ( + echo [WARN] Cannot reach npm registry quickly. + set "USE_MIRROR=1" + ) else ( + echo [OK] npm registry reachable. + ) +) + +if "%USE_MIRROR%"=="1" ( + echo. + echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)... + call :log "Switching to npmmirror.com" + call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1 + call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1 + call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1 + call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1 + call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1 + call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1 + call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1 + call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1 + call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1 + echo [OK] Mirror configured. +) +echo. + +:: ============================================================ +:: Step 4: Install backend dependencies +:: ============================================================ +call :step "4/6" "Installing backend dependencies" + +if exist "node_modules\.package-lock.json" ( + echo Found existing node_modules. Checking integrity... + call :log "Existing node_modules detected, running npm install to verify" +) + +echo Running: npm install ^(this can take 5-15 minutes on slow networks^) +echo Press Ctrl+C to abort. +echo. + +call npm install >>"%LOG_FILE%" 2>&1 +if errorlevel 1 ( + call :error "Backend npm install failed." + echo. + echo Common causes: + echo - Network timeout ^(retry with VPN or check %LOG_FILE%^) + echo - Native module compile failure ^(missing Python/VS Build Tools^) + echo - Disk space full + echo. + echo Try manually: + echo cd /d "%PROJECT_ROOT%" + echo npm install --verbose + echo. + pause + exit /b 1 +) +echo [OK] Backend dependencies installed. +echo. + +:: ============================================================ +:: Step 5: Install frontend dependencies +:: ============================================================ +call :step "5/6" "Installing frontend dependencies" + +if not exist "web\package.json" ( + call :error "web\package.json not found. Repository may be incomplete." + echo Please re-clone the repository: + echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git + pause + exit /b 1 +) + +echo Running: npm install ^(in web/ directory^) +echo. + +pushd web >nul +call npm install >>"%LOG_FILE%" 2>&1 +set "WEB_INSTALL_RESULT=!errorlevel!" +popd >nul + +if !WEB_INSTALL_RESULT! neq 0 ( + call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)." + echo. + echo Try manually: + echo cd /d "%PROJECT_ROOT%\web" + echo npm install --verbose + echo. + pause + exit /b 1 +) +echo [OK] Frontend dependencies installed. +echo. + +:: ============================================================ +:: Step 6: Build project (backend + frontend) +:: ============================================================ +call :step "6/6" "Building project" + +echo Running: npm run build +echo. + +call npm run build >>"%LOG_FILE%" 2>&1 +if errorlevel 1 ( + call :error "Build failed." + echo. + echo Check the log file for details: %LOG_FILE% + echo. + echo Try manually: + echo cd /d "%PROJECT_ROOT%" + echo npm run build echo. - echo Build failed. Check the error messages above. pause exit /b 1 ) echo [OK] Build succeeded. echo. -:: ---- Step 4: Create default config if missing ---- +:: ============================================================ +:: Verify build outputs +:: ============================================================ +echo Verifying build outputs... +set "BUILD_OK=1" + +if not exist "dist" ( + call :error "dist/ directory missing after build." + set "BUILD_OK=0" +) +if not exist "web\dist" ( + call :error "web\dist/ directory missing after build." + set "BUILD_OK=0" +) + +if "!BUILD_OK!"=="0" ( + echo. + echo Build completed but expected output is missing. + echo Check %LOG_FILE% for details. + pause + exit /b 1 +) +echo [OK] Build outputs verified. +echo. + +:: ============================================================ +:: Optional: config.json hint +:: ============================================================ if not exist "config.json" ( - echo Creating default config.json... - echo Please edit config.json with your TeamSpeak server details before starting the bot. + echo [INFO] config.json will be auto-generated on first launch. ) else ( echo [OK] config.json already exists. ) echo. -:: ---- Done ---- +:: ============================================================ +:: Done +:: ============================================================ +call :log "Setup completed successfully at %date% %time%" + echo ============================================ -echo Setup complete! +echo Setup Complete! echo ============================================ echo. -echo To start the bot, run: scripts\start.bat +echo Next steps: +echo 1. Run: scripts\start.bat +echo 2. Open: http://localhost:3000 +echo 3. Follow the in-browser setup wizard. +echo. +echo Setup log saved to: %LOG_FILE% echo. pause +exit /b 0 + +:: ============================================================ +:: Subroutines +:: ============================================================ +:step +echo ---- Step %~1: %~2 ---- +call :log "" +call :log "---- Step %~1: %~2 ----" +goto :eof + +:error +echo. +echo [ERROR] %~1 +call :log "[ERROR] %~1" +goto :eof + +:log +echo [%time%] %~1 >> "%LOG_FILE%" +goto :eof diff --git a/src/audio/player.ts b/src/audio/player.ts index 66fcefe..aeacecb 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -5,11 +5,12 @@ import { accessSync, chmodSync, constants } from "node:fs"; import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js"; import type { Logger } from "../logger.js"; -// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary. const require = createRequire(import.meta.url); const ffmpegPath: string | null = require("ffmpeg-static"); -/** Ensure the given binary has execute permission. */ +/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */ +const globalActivePids = new Set(); + function isExecutable(binPath: string): boolean { try { accessSync(binPath, constants.X_OK); @@ -25,7 +26,6 @@ function isExecutable(binPath: string): boolean { } } -/** Test if an ffmpeg binary actually works by running -version. */ function ffmpegWorks(bin: string): boolean { try { execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" }); @@ -35,39 +35,16 @@ function ffmpegWorks(bin: string): boolean { } } -/** - * Resolved once at module load. - * - * Priority: system FFmpeg → bundled ffmpeg-static. - * - * System-installed FFmpeg is always compatible with the running OS/container, - * while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker - * (passes `ffmpeg -version` but crashes during actual audio processing due to - * incompatible glibc or missing shared libraries). - */ const resolvedFfmpeg: string = (() => { - // 1. Prefer system-installed FFmpeg (always compatible with the runtime) - if (ffmpegWorks("ffmpeg")) { - return "ffmpeg"; - } - - // 2. Fall back to bundled ffmpeg-static binary - // On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS - // it may return a Windows .exe path if node_modules was copied cross-platform. + if (ffmpegWorks("ffmpeg")) return "ffmpeg"; const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false; const onWindows = process.platform === "win32"; - if (ffmpegPath && (onWindows === isWinPath)) { - if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { - return ffmpegPath; - } + if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath; } - - // Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path return "ffmpeg"; })(); -/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */ function getFfmpegCommand(): string { return resolvedFfmpeg; } @@ -93,14 +70,16 @@ export class AudioPlayer extends EventEmitter { private nextFrameTime = 0; private currentUrl = ""; private seekOffset = 0; - private framesPlayed = 0; // ground truth: number of 20ms frames sent + private framesPlayed = 0; private sessionId = 0; - private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo - private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s + private static readonly BUFFER_HIGH_WATER = 640 * 1024; + private static readonly BUFFER_LOW_WATER = 256 * 1024; private ffmpegPaused = false; - private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade) + 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(); @@ -109,112 +88,127 @@ export class AudioPlayer extends EventEmitter { } play(url: string, seekSeconds = 0): void { + // 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 ( this.stop(); - this.sessionId++; - const playSessionId = this.sessionId; + + const currentSessionId = this.sessionId; this.currentUrl = url; this.seekOffset = seekSeconds; this.framesPlayed = 0; + this.healthyFrames = 0; this.ffmpegPaused = false; this.spawnFailed = false; - // Prevent rapid-fire spawn attempts when ffmpeg is broken if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) { - this.logger.error( - { failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() }, - "Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback." - ); + this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached"); this.state = "idle"; - this.emit("error", new Error("ffmpeg unavailable after repeated failures")); + this.emit("error", new Error("ffmpeg unavailable")); return; } - this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback"); - const args: string[] = []; - - // BiliBili CDN requires Referer header for audio playback if (url.includes("bilivideo") || url.includes("bilibili")) { - args.push( - "-headers", - "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n" - ); + args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n"); } - - args.push( - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_delay_max", "5", - ); - if (seekSeconds > 0) { - args.push("-ss", String(seekSeconds)); - } - args.push( - "-i", url, - "-f", "s16le", - "-ar", "48000", - "-ac", "2", - "-acodec", "pcm_s16le", - "-", - ); + args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"); + if (seekSeconds > 0) args.push("-ss", String(seekSeconds)); + args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"); const ffmpegBin = getFfmpegCommand(); - this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary"); this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] }); + + const currentPid = this.ffmpeg.pid; + if (currentPid) { + globalActivePids.add(currentPid); + this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned"); + } - // Prevent stream errors from crashing the process - this.ffmpeg.stdout!.on("error", (err) => { - this.logger.warn({ err }, "FFmpeg stdout error"); - }); - this.ffmpeg.stderr!.on("error", (err) => { - this.logger.warn({ err }, "FFmpeg stderr error"); - }); - - let gotFirstData = false; this.ffmpeg.stdout!.on("data", (chunk: Buffer) => { - if (!gotFirstData) { - gotFirstData = true; - this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received"); + // 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 ( + if (this.sessionId !== currentSessionId) { + return; } + this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); - // Backpressure: pause FFmpeg stdout when buffer is too large if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) { this.ffmpeg.stdout.pause(); this.ffmpegPaused = true; } }); - this.ffmpeg.on("close", (code, signal) => { - this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed"); - if (this.sessionId === playSessionId) { - this.ffmpeg = null; // Signal frame loop that no more data is coming + this.ffmpeg.on("exit", (code, signal) => { + if (currentPid) globalActivePids.delete(currentPid); + this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited"); + + // 只有当前会话的进程结束才置空变量 + if (this.sessionId === currentSessionId) { + this.ffmpeg = null; } }); this.ffmpeg.on("error", (err) => { - this.logger.error({ err }, "FFmpeg error"); - if (this.sessionId === playSessionId) { + if (this.sessionId === currentSessionId) { this.spawnFailed = true; this.consecutiveFailures++; this.emit("error", err); } }); - // Log FFmpeg stderr at info level for debugging playback issues - this.ffmpeg.stderr!.on("data", (data: Buffer) => { - const msg = data.toString().trimEnd(); - // Log important FFmpeg messages at info level - if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) { - this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr"); - } else { - this.logger.debug({ stderr: msg }, "FFmpeg stderr"); - } - }); - this.state = "playing"; this.startFrameLoop(); } + stop(): void { + // 3. 递增 ID 是最有效的逻辑“隔离墙” + this.sessionId++; + this.frameLoopRunning = false; + + // 立即清空缓冲区,确保切歌瞬间静音 ( + this.pcmBuffer = Buffer.alloc(0); + + if (this.ffmpeg) { + const procToKill = this.ffmpeg; + const pidToKill = procToKill.pid; + this.ffmpeg = null; + + if (pidToKill) { + this.forceCleanup(procToKill, pidToKill); + } + } + + this.ffmpegPaused = false; + this.spawnFailed = false; + this.state = "idle"; + this.currentUrl = ""; + this.seekOffset = 0; + this.framesPlayed = 0; + this.healthyFrames = 0; + } + + private forceCleanup(proc: ChildProcess, pid: number): void { + if (!globalActivePids.has(pid)) return; + + try { + proc.kill("SIGTERM"); + } catch (e) { /* ignore */ } + + const killTimeout = setTimeout(() => { + try { + process.kill(pid, 0); + process.kill(pid, "SIGKILL"); + } catch (e) { + } finally { + globalActivePids.delete(pid); + } + }, 1500); + + proc.unref(); + proc.once("exit", () => { + clearTimeout(killTimeout); + globalActivePids.delete(pid); + }); + } + private startFrameLoop(): void { if (this.frameLoopRunning) return; this.frameLoopRunning = true; @@ -224,50 +218,37 @@ export class AudioPlayer extends EventEmitter { private scheduleNextFrame(): void { if (!this.frameLoopRunning) return; - const loopSessionId = this.sessionId; - this.nextFrameTime += FRAME_DURATION_MS; - const now = performance.now(); - const delay = Math.max(0, this.nextFrameTime - now); + const delay = Math.max(0, this.nextFrameTime - performance.now()); setTimeout(() => { - // Discard callback from a stale play session - if (loopSessionId !== this.sessionId) return; - if (!this.frameLoopRunning) return; + // 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 ( + if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return; - if (this.state === "playing") { - this.sendNextFrame(); - } else if (this.state === "paused") { - this.nextFrameTime = performance.now(); - } + if (this.state === "playing") this.sendNextFrame(); + else if (this.state === "paused") this.nextFrameTime = performance.now(); if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { this.frameLoopRunning = false; if (this.state !== "idle") { this.state = "idle"; - // Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade - if (this.spawnFailed) { - this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure"); - } else { - this.consecutiveFailures = 0; // Reset on successful track completion + if (!this.spawnFailed) { + this.consecutiveFailures = 0; this.emit("trackEnd"); } } return; } - this.scheduleNextFrame(); }, delay); } private sendNextFrame(): void { if (this.pcmBuffer.length < PCM_FRAME_BYTES) return; - const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES); this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES); - // Backpressure: resume FFmpeg stdout when buffer drains below low-water mark if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) { this.ffmpeg.stdout.resume(); this.ffmpegPaused = false; @@ -278,101 +259,33 @@ export class AudioPlayer extends EventEmitter { const opusFrame = this.encoder.encode(adjusted); this.emit("frame", opusFrame); this.framesPlayed++; - - if (this.framesPlayed === 1) { - this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted"); - } - // Log every ~10 seconds (500 frames * 20ms = 10s) - if (this.framesPlayed % 500 === 0) { - this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress"); + this.healthyFrames++; + if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) { + this.consecutiveFailures = 0; + this.healthyFrames = 0; } } catch (err) { - this.logger.error({ err }, "Error encoding/sending audio frame"); this.emit("error", err as Error); } } private applyVolume(pcm: Buffer): Buffer { if (this.volume === 100) return Buffer.from(pcm); - const factor = this.volume / 100; + const factor = (this.volume / 100) * 0.2; const out = Buffer.alloc(pcm.length); for (let i = 0; i < pcm.length; i += 2) { - let sample = pcm.readInt16LE(i); - sample = Math.round(sample * factor); - if (sample > 32767) sample = 32767; - else if (sample < -32768) sample = -32768; - out.writeInt16LE(sample, i); + let sample = Math.round(pcm.readInt16LE(i) * factor); + out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i); } return out; } - /** Actual elapsed time in seconds (ground truth from frame count) */ - getElapsed(): number { - return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; - } - - seek(seconds: number): void { - if (!this.currentUrl) return; - // Reject NaN/Infinity/negative — the HTTP layer validates too, but a - // bad value here would poison seekOffset and leave getElapsed() - // returning NaN until the track ends. - if (!Number.isFinite(seconds) || seconds < 0) { - this.logger.warn({ seek: seconds }, "Ignoring invalid seek position"); - return; - } - this.logger.info({ seek: seconds }, "Seeking"); - this.play(this.currentUrl, seconds); - } - - getSeekOffset(): number { - return this.seekOffset; - } - - pause(): void { - if (this.state === "playing") { - this.state = "paused"; - this.logger.debug("Playback paused"); - } - } - - resume(): void { - if (this.state === "paused") { - this.state = "playing"; - this.nextFrameTime = performance.now(); - this.logger.debug("Playback resumed"); - } - } - - stop(): void { - this.sessionId++; - this.frameLoopRunning = false; - if (this.ffmpeg) { - this.ffmpeg.kill("SIGTERM"); - this.ffmpeg = null; - } - this.pcmBuffer = Buffer.alloc(0); - this.ffmpegPaused = false; - this.spawnFailed = false; - this.state = "idle"; - this.currentUrl = ""; - this.seekOffset = 0; - this.framesPlayed = 0; - } - - /** Reset the consecutive failure counter (e.g. after user action) */ - resetFailures(): void { - this.consecutiveFailures = 0; - } - - setVolume(vol: number): void { - this.volume = Math.max(0, Math.min(100, vol)); - } - - getVolume(): number { - return this.volume; - } - - getState(): PlayerState { - return this.state; - } -} + getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; } + seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); } + pause(): void { if (this.state === "playing") this.state = "paused"; } + resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } } + resetFailures(): void { this.consecutiveFailures = 0; } + setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); } + getVolume(): number { return this.volume; } + getState(): PlayerState { return this.state; } +} \ No newline at end of file diff --git a/src/audio/queue.ts b/src/audio/queue.ts index b97444d..f63980c 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -161,4 +161,9 @@ export class PlayQueue { getCurrentIndex(): number { return this.currentIndex; } + + /** Number of songs not yet played in Random mode. */ + unplayedCount(): number { + return this.songs.length - this.playedIndices.size; + } } 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 1bc61b7..8af5c0b 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(); @@ -259,6 +260,7 @@ export class BotInstance extends EventEmitter { "playlist", "album", "fm", + "artist", ]); if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) { throw new Error("Bot is not connected to TeamSpeak"); @@ -298,6 +300,8 @@ export class BotInstance extends EventEmitter { return this.cmdAlbum(cmd); case "fm": return this.cmdFm(); + case "artist": + return this.cmdArtist(cmd); case "vote": return this.cmdVote(msg); case "lyrics": @@ -387,6 +391,7 @@ export class BotInstance extends EventEmitter { const song = result.songs[0]; this.queue.clear(); + this.isFmMode = false; this.queue.add({ ...song, platform: provider.platform }); this.queue.play(); @@ -438,6 +443,7 @@ export class BotInstance extends EventEmitter { private cmdStop(): string { this.player.stop(); this.queue.clear(); + this.isFmMode = false; this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on stop"); }); @@ -491,6 +497,7 @@ export class BotInstance extends EventEmitter { private cmdClear(): string { this.player.stop(); this.queue.clear(); + this.isFmMode = false; this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on clear"); }); @@ -522,13 +529,48 @@ export class BotInstance extends EventEmitter { } private async cmdPlaylist(cmd: ParsedCommand): Promise { - 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(); + this.isFmMode = false; for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); } @@ -545,6 +587,7 @@ export class BotInstance extends EventEmitter { if (songs.length === 0) return "Album is empty or not found"; this.queue.clear(); + this.isFmMode = false; for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); } @@ -566,12 +609,61 @@ export class BotInstance extends EventEmitter { for (const song of songs) { this.queue.add({ ...song, platform: "netease" }); } + this.queue.setMode(PlayMode.Random); + this.isFmMode = true; + this.player.resetFailures(); + const first = this.queue.play(); if (first) await this.resolveAndPlay(first); this.emit("stateChange"); return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; } + private async cmdArtist(cmd: ParsedCommand): Promise { + 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 { + 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); @@ -629,9 +721,12 @@ 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}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`, @@ -661,10 +756,25 @@ export class BotInstance extends EventEmitter { if (!started) { this.player.stop(); this.profileManager.onSongChange(null).catch(() => {}); + } else if (this.isFmMode && this.queue.unplayedCount() <= 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 Random mode, refill and continue + 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 { diff --git a/src/music/qq.ts b/src/music/qq.ts index f91345d..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,33 +44,58 @@ 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.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: [] }; } - async getSongUrl(songId: string, _quality?: string): Promise { - const res = await this.api.get("/getMusicPlay", { - params: { songmid: songId, ...this.cookieParams }, - }); - const playUrl = res.data?.data?.playUrl?.[songId]; - return playUrl?.url || null; + async getSongUrl(songId: string, quality?: string): Promise { + try { + const res = await this.api.get("/getMusicPlay", { + params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams }, + }); + const playUrl = res.data?.data?.playUrl?.[songId]; + if (playUrl?.url) return playUrl.url; + } catch { + // try with songid + try { + const res = await this.api.get("/getMusicPlay", { + params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams }, + }); + const playUrl = res.data?.data?.playUrl?.[songId]; + if (playUrl?.url) return playUrl.url; + } catch { + // ignore + } + } + return null; } async getSongDetail(songId: string): Promise { @@ -115,12 +148,14 @@ export class QQMusicProvider implements MusicProvider { const cdlist = res.data?.response?.cdlist ?? []; if (cdlist.length === 0) return []; return (cdlist[0].songlist ?? []).map((s: any) => ({ - id: String(s.songmid ?? s.songid), + id: String(s.mid ?? s.songmid ?? s.songid), name: s.songname ?? s.name ?? "", artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), album: s.albumname ?? "", duration: s.interval ?? 0, - coverUrl: s.albummid + coverUrl: s.album?.mid + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` + : s.albummid ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` : "", platform: "qq", @@ -255,4 +290,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 []; + } + } } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 330ea49..312ee6a 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -277,35 +277,54 @@ export function createPlayerRouter( }); // Play a single song by ID — resolves URL on demand - router.post("/:botId/play-by-id", async (req, res) => { + router.post("/:botId/play-song", async (req, res) => { try { const bot = (req as any).bot; - const { songId, platform } = req.body; - const provider = bot.getProviderFor( - platform === "bilibili" || platform === "qq" || platform === "youtube" - ? platform - : "netease" - ); - - const song = await provider.getSongDetail(songId); - if (!song) { - res.json({ message: "Song not found" }); + const { song } = req.body; + if (!song || !song.id || !song.platform) { + res.status(400).json({ error: "song object with id and platform is required" }); return; } - const queue = bot.getQueueManager(); queue.clear(); - queue.add({ ...song, platform: provider.platform }); + queue.add(song); queue.play(); bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { - res.json({ message: `Cannot play: ${song.name}` }); + res.json({ message: `Cannot play: ${song.name || song.id}` }); return; } - res.json({ message: `Now playing: ${song.name} - ${song.artist}` }); + res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/:botId/add-song", async (req, res) => { + try { + const bot = (req as any).bot; + const { song } = req.body; + if (!song || !song.id || !song.platform) { + res.status(400).json({ error: "song object with id and platform is required" }); + return; + } + const queue = bot.getQueueManager(); + const wasIdle = bot.getPlayer().getState() === "idle"; + queue.add(song); + + // If nothing was playing, start this newly-added song immediately. + if (wasIdle) { + queue.playAt(queue.size() - 1); + bot.getPlayer().resetFailures(); + await bot.resolveAndPlay(queue.current()!); + res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); + return; + } + + res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } diff --git a/web/package-lock.json b/web/package-lock.json index 2d9e469..c3a65c8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -797,9 +797,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -821,9 +818,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -845,9 +839,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -869,9 +860,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -893,9 +881,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -917,9 +902,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1088,9 +1070,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1105,9 +1084,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1122,9 +1098,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1139,9 +1112,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1156,9 +1126,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1173,9 +1140,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1190,9 +1154,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1207,9 +1168,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1224,9 +1182,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1241,9 +1196,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1258,9 +1210,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1275,9 +1224,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1292,9 +1238,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/web/src/components/Queue.vue b/web/src/components/Queue.vue index 5a654c9..a6a3c04 100644 --- a/web/src/components/Queue.vue +++ b/web/src/components/Queue.vue @@ -3,6 +3,14 @@

播放队列

{{ botQueue.length }} 首 + @@ -70,6 +78,15 @@ async function removeSong(index: number) { // Ignore } } + +async function clearAndStop() { + try { + await store.stop(); + await store.fetchQueue(); + } catch { + // Ignore + } +}