mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional
Major bug fixes and corner-case hardening across the backend, plus a
comprehensive feature test suite. All 94 unit tests + 51 integration
tests pass against a local TS3 server.
Lifecycle & state consistency
-----------------------------
- Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS
handshake no longer blocks the /start HTTP call forever; the failing
instance is torn down and the caller gets a clean 500.
- Bug B: executeCommand rejects audio-dispatching commands (play, add,
next, skip, prev, playlist, album, fm) when the bot is disconnected.
Config-only commands (vol, mode, clear, stop, queue, now, lyrics)
still work so the UI stays usable while offline.
- Bug C: the tsClient 'disconnected' handler always clears player state
now, even when connect() never completed. A separate disconnectEmitted
flag guards duplicate external event emission. Previously an orphaned
connect attempt that idle-timed-out would leave playing=true forever.
- resolveAndPlay re-checks this.connected AFTER the URL-resolve await so
a stop() during the network call can't spawn ffmpeg on a disconnected
bot.
- connect() throws if disconnect() fired during the handshake await,
preventing a concurrent stop from being overwritten by a late connected
flag flip.
- startBot always disconnects the outgoing BotInstance before creating
a replacement, covering the mid-handshake case where isConnected()
still returned false but the library client was live.
- startBot now reuses the stored identity so server groups granted to
the bot survive restarts (was regenerating a fresh UID each time).
WebSocket reliability
---------------------
- BotManager extends EventEmitter and emits 'botInstance' whenever a
new instance is created. websocket.ts listens and re-attaches its
stateChange / connected / disconnected listeners immediately, fixing
the bug where player-bar UI never updated until manual refresh.
- attachedBots map now stores the BotInstance reference and detaches
stale listeners when the instance is replaced. Safety-net interval
(5s) also reconciles to catch anything missed.
- removeBot emits 'botInstanceRemoved' -> WS broadcasts a new
{type:"botRemoved", botId} message. Client drops the bot from its
local store instead of showing it as permanently offline.
HTTP input validation
---------------------
- /volume rejects non-number, NaN, Infinity, and out-of-range values
with a proper 400 instead of a 200 OK wrapping a usage-text string.
- /mode rejects anything not in {seq, loop, random, rloop} with 400.
- /seek rejects NaN / Infinity / negative (previously NaN slipped
through typeof==="number" and poisoned seekOffset).
- /play-at validates index < queue.size() BEFORE stopping current
playback (was silently killing the current song on invalid input).
- /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist
all honour platform=youtube now (previously fell through to netease
and silently played the wrong platform).
YouTube made truly optional
---------------------------
- Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only
positive results so users can install yt-dlp mid-run and have it
picked up without a restart.
- getAuthStatus() returns loggedIn=false with nickname
"YouTube (yt-dlp not installed)" when the binary is missing. UI can
grey out YouTube instead of silently returning empty searches.
- findYtDlp() picks .exe on win32 and bare binary elsewhere.
- /auth/status?platform=youtube now routes to the YouTube provider
instead of falling through to NetEase and leaking the NetEase
user's nickname + avatar.
- /auth/cookie rejects platform=youtube with 400 instead of clobbering
the NetEase cookie entry.
- README documents yt-dlp install paths (bin/ local vs PATH) and adds
a dedicated "Optional: YouTube source" section.
Bot Selector UI
---------------
- New power button in each row of the dropdown with play-state-aware
styling: disabled + wait-cursor during API call, green highlight when
connected, greys out when the bot is offline.
- Dropdown always visible when >=1 bot exists, bigger font + padding.
Queue correctness
-----------------
- PlayQueue.remove(current) now decrements currentIndex so next() in
sequential mode advances to the shifted song. Previously removing
the currently-playing track silently skipped the next track because
current() falsely reported it as active and next() then incremented
past it.
Vote-skip hardening
-------------------
- cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single
voter in an empty channel can't unanimously pass a vote with
needed=0.
- resolveAndPlay clears voteSkipUsers on every new track load so votes
can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths.
cmdAdd parity
-------------
- cmdAdd auto-plays the newly-added song if the player was idle,
matching /api/player/:id/add-by-id behaviour. Previously add'ing to
an empty queue on a connected+idle bot silently enqueued without
starting playback.
Test suite
----------
- scripts/test_full_feature.py — 51 tests across 10 groups exercising
every HTTP endpoint, WebSocket broadcasts, all music providers, bot
lifecycle, disconnected-state corners, seek validation, input
validation, and the main race conditions. Captures and restores the
target bot's initial state. Resilient to TS3 anti-flood via retry
with exponential backoff. Runs against a real local TS3 server.
- scripts/test_rapid_cycle.py — Bugs A/B/C regressions
- scripts/test_corner_cases.py — disconnect-during-connect race, config
commands while disconnected, etc.
- scripts/test_more_corners.py — resolveAndPlay race, seek NaN
- scripts/test_power_button.py — E2E for the new power button
- scripts/test_bot_remove.py — E2E for WS botRemoved broadcast
- scripts/test_playbar.py — player bar auto-show regression (updated
to restore bot state on exit)
- scripts/test_multibot.py — two-bot concurrent playback monitor
- src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6e828b9c2d
commit
4643f70f4a
21 files changed
+2305
-134
No files matched your search
+66
-26
@@ -25,6 +25,14 @@ export function createPlayerRouter(
|
||||
next();
|
||||
});
|
||||
|
||||
/** Map API platform string to the corresponding command flag. */
|
||||
const platformFlag = (platform: unknown): string => {
|
||||
if (platform === "bilibili") return "-b";
|
||||
if (platform === "qq") return "-q";
|
||||
if (platform === "youtube") return "-y";
|
||||
return "";
|
||||
};
|
||||
|
||||
router.post("/:botId/play", async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
@@ -33,8 +41,7 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "query is required" });
|
||||
return;
|
||||
}
|
||||
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||
const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!");
|
||||
const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!");
|
||||
if (!cmd) {
|
||||
res.status(400).json({ error: "Invalid command" });
|
||||
return;
|
||||
@@ -50,8 +57,7 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { query, platform } = req.body;
|
||||
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||
const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!");
|
||||
const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!");
|
||||
if (!cmd) {
|
||||
res.status(400).json({ error: "Invalid command" });
|
||||
return;
|
||||
@@ -85,7 +91,21 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { volume } = req.body;
|
||||
const cmd = parseCommand(`!vol ${volume}`, "!")!;
|
||||
// Reject bad input with a proper 4xx instead of letting cmdVol
|
||||
// return a "Usage:" string inside a 200 body — API clients can't
|
||||
// detect that failure mode, and the UI would silently swallow it.
|
||||
if (
|
||||
typeof volume !== "number" ||
|
||||
!Number.isFinite(volume) ||
|
||||
volume < 0 ||
|
||||
volume > 100
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "volume must be a number between 0 and 100" });
|
||||
return;
|
||||
}
|
||||
const cmd = parseCommand(`!vol ${Math.round(volume)}`, "!")!;
|
||||
const response = await bot.executeCommand(cmd);
|
||||
res.json({ message: response });
|
||||
} catch (err) {
|
||||
@@ -93,10 +113,18 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||
|
||||
router.post("/:botId/mode", async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { mode } = req.body;
|
||||
if (typeof mode !== "string" || !VALID_MODES.has(mode)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "mode must be one of: seq, loop, random, rloop" });
|
||||
return;
|
||||
}
|
||||
const cmd = parseCommand(`!mode ${mode}`, "!")!;
|
||||
const response = await bot.executeCommand(cmd);
|
||||
res.json({ message: response });
|
||||
@@ -116,8 +144,12 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { position } = req.body; // seconds
|
||||
if (typeof position !== "number" || position < 0) {
|
||||
res.status(400).json({ error: "position (seconds) is required" });
|
||||
// typeof NaN === "number" and NaN < 0 is false, so a plain range
|
||||
// check lets NaN/Infinity through and later corrupts seekOffset.
|
||||
if (typeof position !== "number" || !Number.isFinite(position) || position < 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "position must be a finite non-negative number" });
|
||||
return;
|
||||
}
|
||||
bot.getPlayer().seek(position);
|
||||
@@ -153,8 +185,15 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const queue = bot.getQueueManager();
|
||||
bot.getPlayer().stop(); // Stop current playback first
|
||||
bot.getPlayer().resetFailures(); // Reset on user-initiated play
|
||||
// Validate the index BEFORE stopping current playback — otherwise an
|
||||
// invalid index silently kills the user's current song and leaves the
|
||||
// queue idle.
|
||||
if (index >= queue.size()) {
|
||||
res.status(400).json({ error: "Invalid queue index" });
|
||||
return;
|
||||
}
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
const song = queue.playAt(index);
|
||||
if (!song) {
|
||||
res.status(400).json({ error: "Invalid queue index" });
|
||||
@@ -175,9 +214,8 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
|
||||
const cmd = parseCommand(
|
||||
`!playlist ${flags} ${playlistId}`.trim(),
|
||||
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
|
||||
"!"
|
||||
)!;
|
||||
const response = await bot.executeCommand(cmd);
|
||||
@@ -193,11 +231,13 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||
if (!provider) {
|
||||
res.status(500).json({ error: "Provider not available" });
|
||||
return;
|
||||
}
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
// Stop current playback
|
||||
bot.getPlayer().stop();
|
||||
@@ -241,11 +281,11 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||
if (!provider) {
|
||||
res.status(500).json({ error: "Provider not available" });
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
const song = await provider.getSongDetail(songId);
|
||||
if (!song) {
|
||||
@@ -276,11 +316,11 @@ export function createPlayerRouter(
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
|
||||
if (!provider) {
|
||||
res.status(500).json({ error: "Provider not available" });
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
const song = await provider.getSongDetail(songId);
|
||||
if (!song) {
|
||||
|
||||
Reference in new issue
Block a user