mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(ws): re-attach stateChange listeners when bot instance is replaced
startBot creates a fresh BotInstance but the WS layer keyed its listener map by bot.id, so ensureAllBotsAttached skipped the new object and stateChange events were never broadcast — the player bar only appeared after a manual refresh. BotManager now extends EventEmitter and emits "botInstance" whenever a new bot object is created; websocket.ts stores the bot reference, detaches on replacement, and subscribes to the event for immediate wiring. Also enlarges the bot selector (padding 10×20, font 16, min-height 44, bigger dot/chevron/state icons, wider name) and adds Playwright repro scripts for the player bar bug and navbar sizing check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d5d6abeb1e
commit
6e828b9c2d
6 files changed
+161
-22
No files matched your search
@@ -6,3 +6,5 @@ dist/
|
|||||||
config.json
|
config.json
|
||||||
cookies/
|
cookies/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
/bin/
|
||||||
|
scripts/navbar_bigger.png
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||||
|
page.goto("http://localhost:3000")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png")
|
||||||
|
rect = page.locator(".bot-selector-btn").bounding_box()
|
||||||
|
print("bot-selector-btn bbox:", rect)
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Reproduce the player-bar-not-appearing bug."""
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://localhost:3000"
|
||||||
|
|
||||||
|
def get_bot_id():
|
||||||
|
r = requests.get(f"{BASE}/api/bot/")
|
||||||
|
return r.json()["bots"][0]["id"]
|
||||||
|
|
||||||
|
def stop(bot_id):
|
||||||
|
requests.post(f"{BASE}/api/player/{bot_id}/stop")
|
||||||
|
requests.post(f"{BASE}/api/player/{bot_id}/clear")
|
||||||
|
|
||||||
|
def play(bot_id, query="test"):
|
||||||
|
requests.post(f"{BASE}/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
ctx = browser.new_context()
|
||||||
|
ctx.add_init_script("""
|
||||||
|
(() => {
|
||||||
|
const OrigWS = window.WebSocket;
|
||||||
|
window.__wsMessages = [];
|
||||||
|
window.WebSocket = function(...args) {
|
||||||
|
const ws = new OrigWS(...args);
|
||||||
|
ws.addEventListener('message', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
const summary = {type: d.type, botId: d.botId};
|
||||||
|
if (d.status) summary.playing = d.status.playing;
|
||||||
|
if (d.status?.currentSong) summary.song = d.status.currentSong.name;
|
||||||
|
window.__wsMessages.push(summary);
|
||||||
|
console.log('WS_MSG ' + JSON.stringify(summary));
|
||||||
|
} catch(e) {}
|
||||||
|
});
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
Object.assign(window.WebSocket, OrigWS);
|
||||||
|
})();
|
||||||
|
""")
|
||||||
|
page = ctx.new_page()
|
||||||
|
logs = []
|
||||||
|
page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}"))
|
||||||
|
|
||||||
|
bot_id = get_bot_id()
|
||||||
|
stop(bot_id)
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
|
page.goto(BASE)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
# Hook into WebSocket messages from the page to confirm they arrive
|
||||||
|
page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const origWS = window.WebSocket;
|
||||||
|
// Already connected via useWebSocket — tap Pinia store directly
|
||||||
|
// Expose store state via window
|
||||||
|
const store = window.__pinia?._s?.get('player');
|
||||||
|
window.__getStore = () => ({
|
||||||
|
bots: JSON.parse(JSON.stringify(store?.bots || [])),
|
||||||
|
activeBotId: store?.activeBotId,
|
||||||
|
currentSong: store?.currentSong ? JSON.parse(JSON.stringify(store.currentSong)) : null,
|
||||||
|
isPlaying: store?.isPlaying,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
snap_before = page.evaluate("() => window.__getStore?.()")
|
||||||
|
print("store before:", snap_before)
|
||||||
|
|
||||||
|
# Player bar should be absent right now (nothing playing)
|
||||||
|
initial = page.locator(".player-wrapper").count()
|
||||||
|
print(f"initial .player-wrapper count: {initial}")
|
||||||
|
|
||||||
|
# Trigger play via API (simulates any play trigger)
|
||||||
|
play(bot_id, "test")
|
||||||
|
|
||||||
|
# Poll for up to 6s to see if player bar appears automatically
|
||||||
|
appeared_at = None
|
||||||
|
for i in range(60):
|
||||||
|
if page.locator(".player-wrapper").count() > 0:
|
||||||
|
appeared_at = i * 0.1
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
|
||||||
|
print(f"player bar appeared after: {appeared_at}")
|
||||||
|
snap_after = page.evaluate("() => window.__getStore?.()")
|
||||||
|
print("store after:", snap_after)
|
||||||
|
|
||||||
|
# After force reload, does it appear?
|
||||||
|
if appeared_at is None:
|
||||||
|
page.reload()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
time.sleep(0.8)
|
||||||
|
after_reload = page.locator(".player-wrapper").count()
|
||||||
|
print(f"after reload .player-wrapper count: {after_reload}")
|
||||||
|
|
||||||
|
print("--- console logs ---")
|
||||||
|
for line in logs[-20:]:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
+6
-1
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
import {
|
import {
|
||||||
BotInstance,
|
BotInstance,
|
||||||
type BotInstanceOptions,
|
type BotInstanceOptions,
|
||||||
@@ -28,7 +29,7 @@ export interface CreateBotParams {
|
|||||||
serverPassword?: string;
|
serverPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BotManager {
|
export class BotManager extends EventEmitter {
|
||||||
private bots = new Map<string, BotInstance>();
|
private bots = new Map<string, BotInstance>();
|
||||||
private neteaseProvider: MusicProvider;
|
private neteaseProvider: MusicProvider;
|
||||||
private qqProvider: MusicProvider;
|
private qqProvider: MusicProvider;
|
||||||
@@ -46,6 +47,7 @@ export class BotManager {
|
|||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
) {
|
) {
|
||||||
|
super();
|
||||||
this.neteaseProvider = neteaseProvider;
|
this.neteaseProvider = neteaseProvider;
|
||||||
this.qqProvider = qqProvider;
|
this.qqProvider = qqProvider;
|
||||||
this.bilibiliProvider = bilibiliProvider;
|
this.bilibiliProvider = bilibiliProvider;
|
||||||
@@ -82,6 +84,7 @@ export class BotManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
|
this.emit("botInstance", bot);
|
||||||
|
|
||||||
this.database.saveBotInstance({
|
this.database.saveBotInstance({
|
||||||
id,
|
id,
|
||||||
@@ -179,6 +182,7 @@ export class BotManager {
|
|||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
|
this.emit("botInstance", bot);
|
||||||
await bot.connect();
|
await bot.connect();
|
||||||
// Mark as autoStart so it reconnects on Docker restart, and persist identity
|
// Mark as autoStart so it reconnects on Docker restart, and persist identity
|
||||||
this.database.saveBotInstance({ ...saved, autoStart: true });
|
this.database.saveBotInstance({ ...saved, autoStart: true });
|
||||||
@@ -229,6 +233,7 @@ export class BotManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(saved.id, bot);
|
this.bots.set(saved.id, bot);
|
||||||
|
this.emit("botInstance", bot);
|
||||||
|
|
||||||
// Only auto-connect bots that have autoStart enabled
|
// Only auto-connect bots that have autoStart enabled
|
||||||
if (saved.autoStart) {
|
if (saved.autoStart) {
|
||||||
|
|||||||
+27
-12
@@ -10,8 +10,9 @@ export function setupWebSocket(
|
|||||||
): () => void {
|
): () => void {
|
||||||
const clients = new Set<WebSocket>();
|
const clients = new Set<WebSocket>();
|
||||||
|
|
||||||
/** Track which bots already have listeners attached */
|
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
|
||||||
const attachedBots = new Map<string, {
|
const attachedBots = new Map<string, {
|
||||||
|
bot: BotInstance;
|
||||||
stateChange: () => void;
|
stateChange: () => void;
|
||||||
connected: () => void;
|
connected: () => void;
|
||||||
disconnected: () => void;
|
disconnected: () => void;
|
||||||
@@ -48,8 +49,22 @@ export function setupWebSocket(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
function attachBotListener(bot: BotInstance): void {
|
||||||
if (attachedBots.has(bot.id)) return;
|
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 = () => {
|
const onStateChange = () => {
|
||||||
broadcast({
|
broadcast({
|
||||||
@@ -81,6 +96,7 @@ export function setupWebSocket(
|
|||||||
bot.on("disconnected", onDisconnected);
|
bot.on("disconnected", onDisconnected);
|
||||||
|
|
||||||
attachedBots.set(bot.id, {
|
attachedBots.set(bot.id, {
|
||||||
|
bot,
|
||||||
stateChange: onStateChange,
|
stateChange: onStateChange,
|
||||||
connected: onConnected,
|
connected: onConnected,
|
||||||
disconnected: onDisconnected,
|
disconnected: onDisconnected,
|
||||||
@@ -94,21 +110,20 @@ export function setupWebSocket(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for newly added bots periodically
|
// React immediately when a bot instance is created or replaced
|
||||||
|
const onBotInstance = (bot: BotInstance) => attachBotListener(bot);
|
||||||
|
botManager.on("botInstance", onBotInstance);
|
||||||
|
|
||||||
|
// Safety net: periodically re-check in case any bot was missed
|
||||||
const intervalId = setInterval(ensureAllBotsAttached, 5000);
|
const intervalId = setInterval(ensureAllBotsAttached, 5000);
|
||||||
ensureAllBotsAttached();
|
ensureAllBotsAttached();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
// Clean up named listeners
|
botManager.removeListener("botInstance", onBotInstance);
|
||||||
for (const bot of botManager.getAllBots()) {
|
// Clean up all attached listeners (detach from stored bot refs, not live map)
|
||||||
const listeners = attachedBots.get(bot.id);
|
for (const id of Array.from(attachedBots.keys())) {
|
||||||
if (listeners) {
|
detachBotListener(id);
|
||||||
bot.removeListener("stateChange", listeners.stateChange);
|
|
||||||
bot.removeListener("connected", listeners.connected);
|
|
||||||
bot.removeListener("disconnected", listeners.disconnected);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
attachedBots.clear();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -158,13 +158,13 @@ onUnmounted(() => {
|
|||||||
.bot-selector-btn {
|
.bot-selector-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 8px 16px;
|
padding: 10px 20px;
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-height: 36px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -176,13 +176,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-state-mini {
|
.bot-state-mini {
|
||||||
font-size: 11px;
|
font-size: 14px;
|
||||||
&.playing { color: #22c55e; }
|
&.playing { color: #22c55e; }
|
||||||
&.paused { color: #eab308; }
|
&.paused { color: #eab308; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-chevron {
|
.bot-chevron {
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
@@ -192,8 +192,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-dot {
|
.bot-dot {
|
||||||
width: 8px;
|
width: 10px;
|
||||||
height: 8px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -204,7 +204,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-selector-name {
|
.bot-selector-name {
|
||||||
max-width: 120px;
|
max-width: 160px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in new issue
Block a user