mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
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>
149 lines
4.2 KiB
TypeScript
149 lines
4.2 KiB
TypeScript
import { WebSocketServer, WebSocket } from "ws";
|
|
import type { BotManager } from "../bot/manager.js";
|
|
import type { BotInstance } from "../bot/instance.js";
|
|
import type { Logger } from "../logger.js";
|
|
|
|
export function setupWebSocket(
|
|
wss: WebSocketServer,
|
|
botManager: BotManager,
|
|
logger: Logger
|
|
): () => void {
|
|
const clients = new Set<WebSocket>();
|
|
|
|
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
|
|
const attachedBots = new Map<string, {
|
|
bot: BotInstance;
|
|
stateChange: () => void;
|
|
connected: () => void;
|
|
disconnected: () => void;
|
|
}>();
|
|
|
|
wss.on("connection", (ws) => {
|
|
clients.add(ws);
|
|
logger.debug("WebSocket client connected");
|
|
|
|
const bots = botManager.getAllBots().map((b) => b.getStatus());
|
|
ws.send(JSON.stringify({ type: "init", bots }));
|
|
|
|
ws.on("close", () => {
|
|
clients.delete(ws);
|
|
logger.debug("WebSocket client disconnected");
|
|
});
|
|
|
|
ws.on("error", (err) => {
|
|
logger.error({ err }, "WebSocket error");
|
|
clients.delete(ws);
|
|
});
|
|
});
|
|
|
|
const broadcast = (data: object) => {
|
|
const message = JSON.stringify(data);
|
|
for (const client of clients) {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.send(message);
|
|
} catch {
|
|
clients.delete(client);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
function detachBotListener(id: string): void {
|
|
const existing = attachedBots.get(id);
|
|
if (!existing) return;
|
|
existing.bot.removeListener("stateChange", existing.stateChange);
|
|
existing.bot.removeListener("connected", existing.connected);
|
|
existing.bot.removeListener("disconnected", existing.disconnected);
|
|
attachedBots.delete(id);
|
|
}
|
|
|
|
function attachBotListener(bot: BotInstance): void {
|
|
const existing = attachedBots.get(bot.id);
|
|
if (existing) {
|
|
if (existing.bot === bot) return; // already attached to this instance
|
|
// Bot instance was replaced (e.g. startBot re-created it) — re-attach
|
|
detachBotListener(bot.id);
|
|
}
|
|
|
|
const onStateChange = () => {
|
|
broadcast({
|
|
type: "stateChange",
|
|
botId: bot.id,
|
|
status: bot.getStatus(),
|
|
queue: bot.getQueue(),
|
|
});
|
|
};
|
|
|
|
const onConnected = () => {
|
|
broadcast({
|
|
type: "botConnected",
|
|
botId: bot.id,
|
|
status: bot.getStatus(),
|
|
});
|
|
};
|
|
|
|
const onDisconnected = () => {
|
|
broadcast({
|
|
type: "botDisconnected",
|
|
botId: bot.id,
|
|
status: bot.getStatus(),
|
|
});
|
|
};
|
|
|
|
bot.on("stateChange", onStateChange);
|
|
bot.on("connected", onConnected);
|
|
bot.on("disconnected", onDisconnected);
|
|
|
|
attachedBots.set(bot.id, {
|
|
bot,
|
|
stateChange: onStateChange,
|
|
connected: onConnected,
|
|
disconnected: onDisconnected,
|
|
});
|
|
}
|
|
|
|
/** Attach listeners for any new bots that don't have them yet */
|
|
function ensureAllBotsAttached(): void {
|
|
for (const bot of botManager.getAllBots()) {
|
|
attachBotListener(bot);
|
|
}
|
|
}
|
|
|
|
// React immediately when a bot instance is created or replaced
|
|
const onBotInstance = (bot: BotInstance) => attachBotListener(bot);
|
|
botManager.on("botInstance", onBotInstance);
|
|
|
|
// React when a bot is removed: detach its listener and tell clients to drop it
|
|
const onBotInstanceRemoved = (id: string) => {
|
|
detachBotListener(id);
|
|
broadcast({ type: "botRemoved", botId: id });
|
|
};
|
|
botManager.on("botInstanceRemoved", onBotInstanceRemoved);
|
|
|
|
/** Drop attached listeners whose bot is no longer in the manager. */
|
|
function reconcileAttachedBots(): void {
|
|
const liveIds = new Set(botManager.getAllBots().map((b) => b.id));
|
|
for (const id of Array.from(attachedBots.keys())) {
|
|
if (!liveIds.has(id)) detachBotListener(id);
|
|
}
|
|
}
|
|
|
|
// Safety net: periodically re-check in case any bot was missed
|
|
const intervalId = setInterval(() => {
|
|
reconcileAttachedBots();
|
|
ensureAllBotsAttached();
|
|
}, 5000);
|
|
ensureAllBotsAttached();
|
|
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
botManager.removeListener("botInstance", onBotInstance);
|
|
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
|
|
// Clean up all attached listeners (detach from stored bot refs, not live map)
|
|
for (const id of Array.from(attachedBots.keys())) {
|
|
detachBotListener(id);
|
|
}
|
|
};
|
|
}
|