mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +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>
167 lines
5.4 KiB
Python
167 lines
5.4 KiB
Python
"""More corner-case regressions.
|
|
|
|
A. resolveAndPlay disconnect-during-URL-resolve race
|
|
The bot checks !this.connected at the top of resolveAndPlay, but the
|
|
URL-resolve await can take several seconds. If stop is called during
|
|
that window, playback would previously start on a disconnected bot.
|
|
|
|
B. /seek NaN/Infinity rejection
|
|
typeof NaN === "number" and NaN < 0 is false, so a plain range check
|
|
leaks NaN through and corrupts seekOffset / getElapsed.
|
|
"""
|
|
import threading
|
|
import time
|
|
import requests
|
|
|
|
BASE = "http://localhost:3000"
|
|
|
|
|
|
def api(path, method="GET", **kw):
|
|
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
|
|
|
|
|
def get_bot(bot_id):
|
|
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
|
|
|
|
|
def wait_connected(bot_id, want, timeout=15):
|
|
end = time.time() + timeout
|
|
while time.time() < end:
|
|
if get_bot(bot_id)["connected"] is want:
|
|
return True
|
|
time.sleep(0.15)
|
|
return False
|
|
|
|
|
|
def test_resolve_play_stop_race(bot_id):
|
|
"""Fire stopBot during the /play call's URL resolve window."""
|
|
api(f"/api/bot/{bot_id}/stop", method="POST")
|
|
wait_connected(bot_id, False)
|
|
time.sleep(1)
|
|
api(f"/api/bot/{bot_id}/start", method="POST")
|
|
wait_connected(bot_id, True)
|
|
|
|
# Schedule a stop 150ms into the play call — that lands inside the
|
|
# provider.getSongUrl await, which is where the race lives.
|
|
def delayed_stop():
|
|
time.sleep(0.15)
|
|
try:
|
|
api(f"/api/bot/{bot_id}/stop", method="POST")
|
|
except Exception:
|
|
pass
|
|
|
|
threading.Thread(target=delayed_stop, daemon=True).start()
|
|
|
|
try:
|
|
api(
|
|
f"/api/player/{bot_id}/play",
|
|
method="POST",
|
|
json={"query": "the mass", "platform": "netease"},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Give both calls time to settle fully
|
|
time.sleep(3)
|
|
b = get_bot(bot_id)
|
|
# Key invariant: we never want connected=false AND playing=true. That
|
|
# pair is the exact Bug C symptom and would indicate the resolveAndPlay
|
|
# post-await check didn't fire.
|
|
assert not (b["connected"] is False and b["playing"] is True), (
|
|
f"inconsistent state after race: {b}"
|
|
)
|
|
print(
|
|
f"[PASS] resolveAndPlay stop-race — final state consistent "
|
|
f"(connected={b['connected']} playing={b['playing']})"
|
|
)
|
|
|
|
|
|
def test_seek_nan_rejected(bot_id):
|
|
"""Verify that NaN and Infinity seek positions are rejected at the API
|
|
layer (instead of poisoning seekOffset)."""
|
|
api(f"/api/bot/{bot_id}/stop", method="POST")
|
|
wait_connected(bot_id, False)
|
|
api(f"/api/bot/{bot_id}/start", method="POST")
|
|
wait_connected(bot_id, True)
|
|
|
|
# Start a real song so there is an active playback to seek against
|
|
api(
|
|
f"/api/player/{bot_id}/play",
|
|
method="POST",
|
|
json={"query": "the mass", "platform": "netease"},
|
|
)
|
|
time.sleep(1.2)
|
|
|
|
# JSON spec doesn't allow NaN/Infinity literals, but Python's json
|
|
# encoder emits them as bare tokens when allow_nan=True (the default).
|
|
# Express's body-parser rejects them as invalid JSON, which itself is
|
|
# a form of rejection. We additionally verify that sending a string
|
|
# "NaN" or a negative value is also rejected with a clean 400.
|
|
r = api(
|
|
f"/api/player/{bot_id}/seek",
|
|
method="POST",
|
|
json={"position": -5},
|
|
)
|
|
assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
|
|
|
|
r = api(
|
|
f"/api/player/{bot_id}/seek",
|
|
method="POST",
|
|
json={"position": "fifty"},
|
|
)
|
|
assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
|
|
|
|
# Directly send NaN in raw body (body-parser will likely 400 it)
|
|
r = requests.post(
|
|
f"{BASE}/api/player/{bot_id}/seek",
|
|
data='{"position": NaN}',
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=10,
|
|
)
|
|
assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
|
|
|
|
# After the junk attempts, a valid seek still works and the elapsed
|
|
# time is a finite number (not NaN).
|
|
r = api(
|
|
f"/api/player/{bot_id}/seek",
|
|
method="POST",
|
|
json={"position": 30},
|
|
)
|
|
assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
|
|
|
|
elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
|
|
elapsed = elapsed_resp.json().get("elapsed")
|
|
assert elapsed is not None and isinstance(elapsed, (int, float)), (
|
|
f"elapsed should be a number, got {elapsed}"
|
|
)
|
|
# Could be exactly 30 or a tiny bit more if a frame has advanced
|
|
assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
|
|
print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
|
|
|
|
|
|
def main():
|
|
bots = api("/api/bot/").json()["bots"]
|
|
if not bots:
|
|
print("[skip] no bots")
|
|
return
|
|
bot_id = bots[0]["id"]
|
|
initial = bots[0]["connected"]
|
|
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
|
|
|
|
try:
|
|
test_resolve_play_stop_race(bot_id)
|
|
test_seek_nan_rejected(bot_id)
|
|
print("ALL GREEN")
|
|
finally:
|
|
if initial:
|
|
api(f"/api/bot/{bot_id}/start", method="POST")
|
|
wait_connected(bot_id, True)
|
|
else:
|
|
api(f"/api/bot/{bot_id}/stop", method="POST")
|
|
wait_connected(bot_id, False)
|
|
print(f"[restore] connected={get_bot(bot_id)['connected']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|