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
@@ -0,0 +1,212 @@
|
||||
"""Stress-test two bots playing music concurrently on the same TS server.
|
||||
|
||||
Creates two temporary bots (or reuses existing named ones), starts them,
|
||||
plays music on both, and polls /api/bot/ every 2 seconds to detect when
|
||||
(if) either bot disconnects or stops playing. Cleans up on exit.
|
||||
|
||||
Usage:
|
||||
python scripts/test_multibot.py --minutes 3
|
||||
python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://localhost:3000"
|
||||
POLL_INTERVAL = 2.0
|
||||
TEST_BOT_NAMES = ("mbtest1", "mbtest2")
|
||||
TEST_BOT_NICKS = ("MBTest1", "MBTest2")
|
||||
QUERIES = ("the mass", "lofi") # one different song per bot
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotSnapshot:
|
||||
t: float
|
||||
connected: bool
|
||||
playing: bool
|
||||
song: str | None
|
||||
|
||||
|
||||
def api_get(path: str):
|
||||
r = requests.get(f"{API}{path}", timeout=5)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_post(path: str, json=None):
|
||||
r = requests.post(f"{API}{path}", json=json, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_delete(path: str):
|
||||
r = requests.delete(f"{API}{path}", timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def cleanup_existing(names: tuple[str, ...]) -> None:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
for b in bots:
|
||||
if b["name"] in names:
|
||||
try:
|
||||
api_post(f"/api/player/{b['id']}/stop")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
api_delete(f"/api/bot/{b['id']}")
|
||||
print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] failed to remove {b['name']}: {e}")
|
||||
|
||||
|
||||
def create_bot(name: str, nickname: str, host: str, port: int) -> str:
|
||||
res = api_post(
|
||||
"/api/bot/",
|
||||
json={
|
||||
"name": name,
|
||||
"serverAddress": host,
|
||||
"serverPort": port,
|
||||
"nickname": nickname,
|
||||
"autoStart": False,
|
||||
},
|
||||
)
|
||||
bot_id = res["id"]
|
||||
print(f"[create] {name} -> {bot_id}")
|
||||
return bot_id
|
||||
|
||||
|
||||
def start_bot(bot_id: str) -> None:
|
||||
api_post(f"/api/bot/{bot_id}/start")
|
||||
|
||||
|
||||
def play(bot_id: str, query: str) -> None:
|
||||
api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
|
||||
|
||||
|
||||
def snapshot(bot_id: str, t0: float) -> BotSnapshot:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
b = next((x for x in bots if x["id"] == bot_id), None)
|
||||
if not b:
|
||||
return BotSnapshot(time.time() - t0, False, False, None)
|
||||
song = b["currentSong"]["name"] if b.get("currentSong") else None
|
||||
return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
|
||||
|
||||
|
||||
def run(minutes: float, host: str, port: int) -> int:
|
||||
print(f"[setup] duration={minutes}min host={host}:{port}")
|
||||
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
|
||||
bot_ids = [
|
||||
create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
|
||||
create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
|
||||
]
|
||||
|
||||
# Start both, allowing a small stagger to avoid handshake collision
|
||||
for i, bid in enumerate(bot_ids):
|
||||
start_bot(bid)
|
||||
print(f"[start] bot{i+1} started")
|
||||
time.sleep(1.5)
|
||||
|
||||
# Wait until both are connected (or bail after 15s)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
|
||||
if all(bots[b]["connected"] for b in bot_ids):
|
||||
print("[start] both bots connected")
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("[fatal] bots did not both come online in 15s")
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
return 2
|
||||
|
||||
# Kick off playback on both
|
||||
for i, bid in enumerate(bot_ids):
|
||||
play(bid, QUERIES[i])
|
||||
print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
|
||||
|
||||
t0 = time.time()
|
||||
end = t0 + minutes * 60
|
||||
first_drop: dict[str, float] = {}
|
||||
last_state: dict[str, BotSnapshot] = {}
|
||||
|
||||
print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
|
||||
print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
|
||||
|
||||
def fmt(snap: BotSnapshot) -> str:
|
||||
flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
|
||||
song = (snap.song or "").replace("\n", " ")[:30]
|
||||
return f"{flag} {song}"
|
||||
|
||||
try:
|
||||
while time.time() < end:
|
||||
snaps = [snapshot(bid, t0) for bid in bot_ids]
|
||||
elapsed = int(time.time() - t0)
|
||||
row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
|
||||
# Only print when state changes or every 10s
|
||||
changed = False
|
||||
for bid, s in zip(bot_ids, snaps):
|
||||
prev = last_state.get(bid)
|
||||
if (prev is None
|
||||
or prev.connected != s.connected
|
||||
or prev.playing != s.playing
|
||||
or prev.song != s.song):
|
||||
changed = True
|
||||
last_state[bid] = s
|
||||
if not s.connected and bid not in first_drop:
|
||||
first_drop[bid] = s.t
|
||||
if changed or elapsed % 10 == 0:
|
||||
print(row)
|
||||
|
||||
# If both stopped playing but are still connected, re-queue the same song
|
||||
for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
|
||||
if s.connected and not s.playing:
|
||||
try:
|
||||
play(bid, QUERIES[i])
|
||||
except Exception as e:
|
||||
print(f"[warn] re-play bot{i+1} failed: {e}")
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[abort] interrupted")
|
||||
|
||||
# Summary
|
||||
total = time.time() - t0
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Total observed time: {total:.1f}s")
|
||||
for i, bid in enumerate(bot_ids):
|
||||
drop = first_drop.get(bid)
|
||||
if drop is None:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
|
||||
else:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
|
||||
print("=" * 60)
|
||||
|
||||
# Cleanup
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
print("[cleanup] done")
|
||||
|
||||
return 0 if not first_drop else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--minutes", type=float, default=3.0)
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=9987)
|
||||
args = p.parse_args()
|
||||
try:
|
||||
return run(args.minutes, args.host, args.port)
|
||||
except requests.HTTPError as e:
|
||||
print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user