mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge pull request #98 from ZHANGTIANYAO1/fix/autopause-resume-on-return
fix(auto-pause): auto-resume when a listener returns (event-driven)
This commit is contained in:
5 files changed
+128
-8
No files matched your search
@@ -99,3 +99,40 @@ track *we* auto-paused gets auto-resumed; a user-paused track stays paused when
|
|||||||
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||||
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||||
no extra channel subscription needed.
|
no extra channel subscription needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered
|
||||||
|
|
||||||
|
Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2)
|
||||||
|
invalidated two assumptions above. Recording the corrected model here so nobody
|
||||||
|
reintroduces the old design:
|
||||||
|
|
||||||
|
- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts`
|
||||||
|
and the rationale comment there.
|
||||||
|
- **Query commands are unusable when others are present.** `clientlist`,
|
||||||
|
`channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2
|
||||||
|
clients are connected to the **server** (verified even when the two clients
|
||||||
|
are in *different* channels). They succeed only when the bot is the sole
|
||||||
|
client on the whole server. So `getClientsInChannel()` returns `[]` exactly
|
||||||
|
when occupancy matters, and `occupancyFromClientList(0)` returns `null`
|
||||||
|
("unknown") so callers skip the decision rather than mis-reading it as empty.
|
||||||
|
- **PAUSE** therefore only ever fires when the bot becomes alone on the server
|
||||||
|
(the one state where the query works). This is reliable and stays on the
|
||||||
|
query path (`refreshOccupancy()` + the 30s idle poller).
|
||||||
|
- **RESUME** is armed directly from the `clientEnter` push event
|
||||||
|
(`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from
|
||||||
|
a query. Because the bot only auto-pauses while alone, the sole way occupancy
|
||||||
|
can return while `autoPaused` is set is a fresh connection — delivered as
|
||||||
|
`clientEnter`. The resume branch never pauses (userCount is always > 0).
|
||||||
|
- **Net semantics:** "pause when the server is empty (bot alone), resume when
|
||||||
|
someone connects." Channel granularity is **impossible** with this library:
|
||||||
|
`clientEnter`'s channel field is always `0` (library reads notify param `cid`
|
||||||
|
but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT
|
||||||
|
attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is
|
||||||
|
systematically wrong for direct-connect clients and reintroduces unreliability.
|
||||||
|
The correct path to true channel scoping is an upstream library fix.
|
||||||
|
- **Knock-on:** idle-disconnect shares the same signal and is likewise
|
||||||
|
server-wide. UI copy in `web/src/views/Settings.vue` was updated to say
|
||||||
|
"服务器" rather than "频道" to match. `cmdVote` was intentionally left on the
|
||||||
|
query path (out of scope; switching it would inherit the same timeout).
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
|
import {
|
||||||
|
decideOccupancyAction,
|
||||||
|
occupancyFromClientList,
|
||||||
|
shouldResumeOnReturn,
|
||||||
|
} from "./auto-pause.js";
|
||||||
|
|
||||||
describe("decideOccupancyAction", () => {
|
describe("decideOccupancyAction", () => {
|
||||||
it("pauses when empty while playing and enabled", () => {
|
it("pauses when empty while playing and enabled", () => {
|
||||||
@@ -45,3 +49,25 @@ describe("occupancyFromClientList", () => {
|
|||||||
expect(occupancyFromClientList(-3)).toBeNull();
|
expect(occupancyFromClientList(-3)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldResumeOnReturn (event-driven auto-resume)", () => {
|
||||||
|
it("resumes when we auto-paused and are still paused", () => {
|
||||||
|
// The reported gap: someone returns after an auto-pause. clientlist can't
|
||||||
|
// confirm it (it times out while they're present), so we resume from the
|
||||||
|
// clientEnter event alone.
|
||||||
|
expect(shouldResumeOnReturn(true, "paused")).toBe(true);
|
||||||
|
});
|
||||||
|
it("does NOT resume a track the user paused by hand", () => {
|
||||||
|
expect(shouldResumeOnReturn(false, "paused")).toBe(false);
|
||||||
|
});
|
||||||
|
it("does nothing if already playing (e.g. the bot's own enter at connect)", () => {
|
||||||
|
// autoPaused is cleared to false on connect, so the bot's own clientEnter
|
||||||
|
// is a no-op; this also covers the playing/auto-paused-flag-stale case.
|
||||||
|
expect(shouldResumeOnReturn(false, "playing")).toBe(false);
|
||||||
|
expect(shouldResumeOnReturn(true, "playing")).toBe(false);
|
||||||
|
});
|
||||||
|
it("does nothing when idle (nothing to resume)", () => {
|
||||||
|
expect(shouldResumeOnReturn(true, "idle")).toBe(false);
|
||||||
|
expect(shouldResumeOnReturn(false, "idle")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,3 +40,28 @@ export function decideOccupancyAction(
|
|||||||
if (autoPaused && playerState === "paused") return "resume";
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a client-presence push event (a `clientEnter`) should trigger an
|
||||||
|
* auto-resume, WITHOUT consulting a clientlist query.
|
||||||
|
*
|
||||||
|
* Why event-driven: the full-client `clientlist`/`channellist` commands time
|
||||||
|
* out whenever ≥2 clients are connected to the server (a library limitation) —
|
||||||
|
* which is exactly the moment a listener returns. So occupancy cannot be
|
||||||
|
* re-queried to confirm the return; we must act on the push event itself.
|
||||||
|
* This is sound because the bot only ever auto-pauses while it is alone on the
|
||||||
|
* server (the sole state in which the occupancy query succeeds and pause
|
||||||
|
* fires). Therefore, while `autoPaused` is true, the only way occupancy can
|
||||||
|
* return is a fresh connection — delivered reliably as `clientEnter`.
|
||||||
|
*
|
||||||
|
* Gating on `autoPaused` (not merely "paused") guarantees we never revive a
|
||||||
|
* track the user paused by hand, and makes the bot's own `clientEnter` at
|
||||||
|
* connect a no-op (autoPaused is cleared to false on connect). This predicate
|
||||||
|
* NEVER pauses — pause stays on the authoritative clientlist path.
|
||||||
|
*/
|
||||||
|
export function shouldResumeOnReturn(
|
||||||
|
autoPaused: boolean,
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
): boolean {
|
||||||
|
return autoPaused && playerState === "paused";
|
||||||
|
}
|
||||||
+33
-2
@@ -18,7 +18,11 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
|||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
|
import {
|
||||||
|
decideOccupancyAction,
|
||||||
|
occupancyFromClientList,
|
||||||
|
shouldResumeOnReturn,
|
||||||
|
} from "./auto-pause.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -166,11 +170,38 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
// React near-instantly to channel membership changes. The 30s idle
|
// React near-instantly to channel membership changes. The 30s idle
|
||||||
// poller remains the fallback if any of these events are missed.
|
// poller remains the fallback if any of these events are missed.
|
||||||
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
|
//
|
||||||
|
// clientEnter additionally arms auto-RESUME directly from the event,
|
||||||
|
// because the occupancy query (clientlist) times out whenever another
|
||||||
|
// client is present — i.e. exactly when a listener returns — so it cannot
|
||||||
|
// be used to confirm the return. See _resumeIfReturning().
|
||||||
|
this.tsClient.on("clientEnter", () => {
|
||||||
|
this._resumeIfReturning();
|
||||||
|
void this.refreshOccupancy();
|
||||||
|
});
|
||||||
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||||
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume playback when a listener returns after an auto-pause, driven by the
|
||||||
|
* clientEnter push event rather than a (timing-out) occupancy query.
|
||||||
|
*
|
||||||
|
* We only auto-pause while alone on the server, so `autoPaused` is a reliable
|
||||||
|
* "paused because empty" flag; any client appearing while it's set means a
|
||||||
|
* listener returned. Delegating to handleOccupancy(1) routes through
|
||||||
|
* decideOccupancyAction (resume iff autoPaused && paused) and also cancels the
|
||||||
|
* idle-disconnect timer. This path NEVER pauses — userCount is always > 0 —
|
||||||
|
* so a spurious or unrelated enter can only (harmlessly) resume, never stop
|
||||||
|
* playback. Pause remains exclusively on the authoritative clientlist path.
|
||||||
|
*/
|
||||||
|
private _resumeIfReturning(): void {
|
||||||
|
if (!this.connected) return;
|
||||||
|
if (shouldResumeOnReturn(this.autoPaused, this.player.getState())) {
|
||||||
|
this.handleOccupancy(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshOccupancy(): Promise<void> {
|
private async refreshOccupancy(): Promise<void> {
|
||||||
if (!this.connected) return;
|
if (!this.connected) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -417,7 +417,7 @@
|
|||||||
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
|
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
|
||||||
<div>
|
<div>
|
||||||
<div>闲置自动退出</div>
|
<div>闲置自动退出</div>
|
||||||
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
|
<div style="font-size:12px; opacity:0.6; margin-top:2px">服务器上没有其他人时,机器人自动断开的等待时间(0 = 不退出)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prefix-input-wrap">
|
<div class="prefix-input-wrap">
|
||||||
@@ -435,8 +435,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="profile-toggle behavior-toggle">
|
<label class="profile-toggle behavior-toggle">
|
||||||
<div class="profile-toggle-text">
|
<div class="profile-toggle-text">
|
||||||
<div class="profile-toggle-label">频道无人时自动暂停播放</div>
|
<div class="profile-toggle-label">无人时自动暂停播放</div>
|
||||||
<div class="profile-toggle-hint">机器人所在频道没有其他人时自动暂停,有人加入后可继续播放</div>
|
<div class="profile-toggle-hint">服务器上只剩机器人自己时自动暂停,有人连接后自动继续播放(受协议限制,占用判断以整个服务器为准,无法精确到单个频道)</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="autoPauseOnEmpty"
|
v-model="autoPauseOnEmpty"
|
||||||
@@ -955,13 +955,14 @@ async function savePrefix() {
|
|||||||
|
|
||||||
// Idle timeout
|
// Idle timeout
|
||||||
const idleTimeout = ref(0);
|
const idleTimeout = ref(0);
|
||||||
const autoPauseOnEmpty = ref(true);
|
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
|
||||||
|
const autoPauseOnEmpty = ref(false);
|
||||||
|
|
||||||
async function loadIdleTimeout() {
|
async function loadIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/bot/settings');
|
const res = await axios.get('/api/bot/settings');
|
||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user