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:
TIANYAO ZHANG authored and GitHub committed 2026-06-17 23:12:27 +08:00
commit 6f21b6354a
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).
+27 -1
View File
@@ -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);
});
});
+25
View File
@@ -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
View File
@@ -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 {
+6 -5
View File
@@ -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 */ }
} }