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:
saopig1andClaude Opus 4.6 committed 2026-04-11 01:35:14 +08:00
1 parent 6e828b9c2d
commit 4643f70f4a
21 files changed
+2305 -134

No files matched your search

+7
View File
@@ -302,6 +302,13 @@ export class AudioPlayer extends EventEmitter {
seek(seconds: number): void {
if (!this.currentUrl) return;
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
// bad value here would poison seekOffset and leave getElapsed()
// returning NaN until the track ends.
if (!Number.isFinite(seconds) || seconds < 0) {
this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
return;
}
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
+43
View File
@@ -89,6 +89,49 @@ describe("PlayQueue", () => {
expect(queue.list()[1].id).toBe("3");
});
it("removing a song before current shifts current index", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.playAt(2); // playing C at index 2
queue.remove(0); // remove A (before current)
expect(queue.current()?.id).toBe("C"); // still on C
expect(queue.getCurrentIndex()).toBe(1);
});
it("removing the currently-playing song lets next() advance to the shifted song", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.playAt(2); // playing C
queue.remove(2); // remove C — D shifts into slot 2
// Before the fix this returned null (D was silently skipped)
expect(queue.next()?.id).toBe("D");
});
it("removing the only song clears the queue", () => {
queue.add(makeSong("only"));
queue.playAt(0);
queue.remove(0);
expect(queue.size()).toBe(0);
expect(queue.current()).toBeNull();
expect(queue.next()).toBeNull();
});
it("removing the last song while playing it advances to null in sequential mode", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.playAt(1); // playing B (last)
queue.remove(1);
expect(queue.size()).toBe(1);
// currentIndex moved to 0, so next() should try to advance past the end
expect(queue.next()).toBeNull();
});
it("clears all songs", () => {
queue.add(makeSong("1"));
queue.add(makeSong("2"));
+8 -3
View File
@@ -36,9 +36,14 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
if (this.currentIndex >= this.songs.length) {
this.currentIndex = this.songs.length - 1;
}
// Move the pointer back by one so next() in sequential mode advances
// to the song that shifted into the removed slot. Without this, the
// shifted song is silently skipped because current() incorrectly
// returns it (even though the player is still on the removed song)
// and next() then increments past it. currentIndex may become -1,
// which is fine — it represents "no current song" and next() will
// pick index 0.
this.currentIndex--;
}
return removed;
+77 -8
View File
@@ -57,6 +57,7 @@ export class BotInstance extends EventEmitter {
private config: BotConfig;
private logger: Logger;
private connected = false;
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
@@ -108,28 +109,42 @@ export class BotInstance extends EventEmitter {
});
this.tsClient.on("disconnected", () => {
// Avoid duplicate event if disconnect() was called explicitly
// (it already set connected = false and emitted "disconnected").
if (!this.connected) return;
// Always reset local state — covers the case where connect() never
// completed (hanging handshake → 60s library idle timeout) and
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
// Only emit externally once per lifecycle so clients don't see a
// duplicate "disconnected" after an explicit disconnect() call.
if (this.disconnectEmitted) return;
this.disconnectEmitted = true;
this.emit("disconnected");
});
}
async connect(): Promise<void> {
this.disconnectEmitted = false;
await this.tsClient.connect();
// Race guard: if disconnect() was called while the handshake was
// awaiting, don't flip connected back to true — that would leave the
// bot in an inconsistent state (externally "connected" but the tsClient
// has already been torn down).
if (this.disconnectEmitted) {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
this.emit("connected");
}
disconnect(): void {
this.player.stop();
// Set connected = false BEFORE tsClient.disconnect() so that the
// "disconnected" event handler (setupTsEvents) won't emit a duplicate.
this.connected = false;
if (!this.disconnectEmitted) {
this.disconnectEmitted = true;
this.emit("disconnected");
}
this.tsClient.disconnect();
this.emit("disconnected");
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
@@ -170,6 +185,24 @@ export class BotInstance extends EventEmitter {
cmd: ParsedCommand,
msg?: TS3TextMessage
): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or
// torn-down TS client, leaving player.state="playing" on a disconnected
// bot. Config-only commands (vol, mode, clear, stop, queue, now) are
// still allowed so the UI stays usable while the bot is offline.
const AUDIO_COMMANDS = new Set([
"play",
"add",
"next",
"skip",
"prev",
"playlist",
"album",
"fm",
]);
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
throw new Error("Bot is not connected to TeamSpeak");
}
switch (cmd.name) {
case "play":
return this.cmdPlay(cmd);
@@ -235,6 +268,14 @@ export class BotInstance extends EventEmitter {
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
if (!this.connected) {
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
return false;
}
// Clear any accumulated skip votes — every fresh track starts with a
// clean slate, regardless of which code path loaded it (cmdPlay,
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
const url = await provider.getSongUrl(song.id);
@@ -242,6 +283,18 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
return false;
}
// Re-check connection state AFTER the network round-trip — the URL
// resolve can take multiple seconds and the user may have called stop
// during that window. Without this, we'd spawn ffmpeg on a
// disconnected bot and land back in the same "connected=false but
// playing=true" inconsistency that Bug C was about.
if (!this.connected) {
this.logger.warn(
{ songId: song.id, name: song.name },
"bot disconnected during URL resolve — aborting playback",
);
return false;
}
song.url = url;
this.player.play(url);
this.database.addPlayHistory({
@@ -288,7 +341,20 @@ export class BotInstance extends EventEmitter {
return `No results found for: ${cmd.args}`;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...song, platform: provider.platform });
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
// the same to the user (add to idle bot → plays now).
if (wasIdle) {
this.queue.playAt(this.queue.size() - 1);
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
return `Now playing: ${song.name} - ${song.artist}`;
}
this.emit("stateChange");
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
}
@@ -440,8 +506,11 @@ export class BotInstance extends EventEmitter {
if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid);
const clients = await this.tsClient.getClientsInChannel();
const totalUsers = clients.length - 1;
const needed = Math.ceil(totalUsers / 2);
const totalUsers = clients.length - 1; // exclude the bot itself
// At least 1 vote is always required — otherwise a single voter in an
// otherwise empty channel (or a transient clients.length=1 race) could
// unanimously "win" with needed=0.
const needed = Math.max(1, Math.ceil(totalUsers / 2));
const votes = this.voteSkipUsers.size;
if (votes >= needed) {
+55 -2
View File
@@ -12,6 +12,42 @@ import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
* server silently drops the connection after initivexpand2), we tear the
* instance down instead of waiting for the library's 60s idle timeout, so
* the HTTP /start call returns promptly and the UI doesn't lock up.
*/
async function connectWithTimeout(
bot: BotInstance,
ms: number,
logger: Logger
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`connect timeout after ${ms}ms`)),
ms
);
});
try {
await Promise.race([bot.connect(), timeout]);
} catch (err) {
logger.warn(
{ err, botId: bot.id },
"Connect failed or timed out — tearing down instance"
);
try {
bot.disconnect();
} catch {
// ignore teardown errors
}
throw err;
} finally {
if (timer) clearTimeout(timer);
}
}
export interface CreateBotParams {
name: string;
serverAddress: string;
@@ -111,6 +147,7 @@ export class BotManager extends EventEmitter {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -155,6 +192,18 @@ export class BotManager extends EventEmitter {
const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`);
// Always tear down the outgoing instance before creating a replacement.
// Covers three cases:
// 1. oldBot is fully connected (manual restart)
// 2. oldBot is mid-handshake from a prior rapid start (isConnected()
// still returns false but the library client is live and will leak
// a TS session if we abandon it)
// 3. oldBot was just created by createBot but never connected — the
// disconnect call is a cheap no-op here.
// Calling disconnect() is idempotent (disconnectEmitted guards event
// emission), so this is safe in all states.
oldBot.disconnect();
// Reload config from database so updated settings (channel, nickname, etc.) take effect
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
@@ -167,6 +216,10 @@ export class BotManager extends EventEmitter {
port: saved.serverPort,
queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
// Reuse the stored identity so server groups assigned to this bot
// survive restarts — without this the TS server sees a new UID
// each connect and strips all previously granted groups.
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
@@ -183,12 +236,12 @@ export class BotManager extends EventEmitter {
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
await bot.connect();
await connectWithTimeout(bot, 15_000, this.logger);
// Mark as autoStart so it reconnects on Docker restart, and persist identity
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
} else {
await oldBot.connect();
await connectWithTimeout(oldBot, 15_000, this.logger);
}
}
+55 -7
View File
@@ -21,17 +21,54 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */
function findYtDlp(): string {
const exe = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
const candidates = [
join(__dirname, "..", "..", "bin", "yt-dlp.exe"),
join(__dirname, "..", "..", "bin", exe),
join(__dirname, "..", "..", "bin", "yt-dlp"),
"yt-dlp",
"yt-dlp.exe",
exe,
];
for (const c of candidates) {
if (!c.includes(join("bin", "yt-dlp")) || existsSync(c)) return c;
// For PATH entries (no directory prefix), fall through to let execFile try
// Absolute/relative paths: only return if the file exists.
// Bare names: return and let execFile resolve via PATH.
const isBinPath = c.includes(join("bin", "yt-dlp"));
if (!isBinPath || existsSync(c)) return c;
}
return "yt-dlp";
return exe;
}
/**
* Availability check for yt-dlp. Runs `yt-dlp --version` and caches only
* the positive result — if the binary is missing, subsequent calls retry
* so the user can install yt-dlp while the server is running and pick it
* up without a restart. Used by getAuthStatus() so the UI can reflect
* whether YouTube is actually usable.
*/
let cachedAvailable = false;
let pendingCheck: Promise<boolean> | null = null;
async function checkYtDlpAvailable(): Promise<boolean> {
if (cachedAvailable) return true;
if (pendingCheck) return pendingCheck;
pendingCheck = (async () => {
try {
await execFileAsync(findYtDlp(), ["--version"], {
timeout: 5_000,
maxBuffer: 1024,
});
cachedAvailable = true;
return true;
} catch {
return false;
} finally {
pendingCheck = null;
}
})();
return pendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetYtDlpAvailabilityCache(): void {
cachedAvailable = false;
pendingCheck = null;
}
async function runYtDlp(args: string[], timeoutMs = 30_000): Promise<string> {
@@ -179,6 +216,17 @@ export class YouTubeProvider implements MusicProvider {
getCookie(): string { return ""; }
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
// YouTube has no login concept via yt-dlp, so "loggedIn" here means
// "yt-dlp binary is reachable and responds to --version". The UI can
// use this flag to grey out YouTube when the optional dependency is
// missing, instead of silently returning empty search results.
const available = await checkYtDlpAvailable();
if (available) {
return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
}
return {
loggedIn: false,
nickname: "YouTube (yt-dlp not installed)",
};
}
}
+13
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
@@ -11,9 +12,13 @@ export function createAuthRouter(
cookieStore?: CookieStore
): Router {
const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can
// report whether yt-dlp is actually installed (loggedIn=false otherwise).
const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -119,6 +124,14 @@ export function createAuthRouter(
res.status(400).json({ error: "cookie is required" });
return;
}
// YouTube has no cookie concept — reject instead of falling through and
// clobbering the NetEase cookie entry.
if (platform === "youtube") {
res
.status(400)
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
return;
}
const provider = getProvider(platform);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
+66 -26
View File
@@ -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) {
+20 -1
View File
@@ -114,13 +114,32 @@ export function setupWebSocket(
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(ensureAllBotsAttached, 5000);
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);