Follow-up to the auto-pause fix: resume never fired when someone came back. Root cause (verified live against a TS3 server): the full-client library's command/response channel is dead whenever >=2 clients are connected anywhere on the server — clientlist, channellist and channelclientlist ALL time out (confirmed even with the two clients in different channels). So the moment a listener returns is exactly the moment occupancy can no longer be queried, and the query-based refreshOccupancy() can never observe the return -> no resume. Event channelID is also unusable (library reads notify `cid` but enter-view carries `ctid`, so it's always 0), so per-channel membership can't be derived from events either. Fix (minimal, asymmetric): keep PAUSE on the authoritative clientlist path (reliable precisely because it only succeeds when the bot is alone on the server — the only state pause should fire), and arm RESUME directly from the clientEnter push event. Because the bot only auto-pauses while alone, the sole way occupancy can return while autoPaused is set is a fresh connection, which arrives reliably as clientEnter. New pure predicate shouldResumeOnReturn() + _resumeIfReturning() resume iff autoPaused && paused; the resume branch routes through handleOccupancy(1) and NEVER pauses (userCount>0), so a spurious enter can only harmlessly resume. The bot's own enter at connect is a no-op (autoPaused is already false). This deliberately does NOT adopt a full event-tracked peer set: events don't reliably seed clients already present when the bot joins, so a count-from-events ==0 would reintroduce the false-pause bug we just fixed, and reconcile can't heal it (clientlist only works when alone). Pause must trust only the authoritative query; resume can trust the event. Net semantics: pause when the server is empty (bot alone), resume when someone connects. Channel granularity is impossible with this library. UI copy updated to say "服务器" instead of "频道", and the Settings toggle default corrected to false to match the backend default. cmdVote intentionally left as-is. Verified live: auto-paused bot + a real client connecting -> resume fires with no clientlist call in the path; bot's own enter and not-auto-paused enters do not resume. 311 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.0 KiB
Auto-pause on empty channel — design
Issue: #79 item 3 Date: 2026-05-30 Status: Approved (brainstorm), pending implementation plan
Problem
When everyone leaves the bot's voice channel, music keeps playing to an empty room. The maintainer wants an option to auto-pause when the channel is empty (no disconnect) and resume when someone returns.
Decisions (from brainstorm)
- Global toggle, reusing the already-declared but currently dead
config.autoPauseOnEmpty(src/data/config.ts, defaulttrue). No per-bot granularity (YAGNI). - Event-driven, near-instant reaction (not the 30s poll alone) — subscribe to TS client enter/leave/move events; keep the existing 30s idle poll as a fallback.
- Auto-resume only what we auto-paused — a user-paused track is never auto-resumed.
- Independent of the existing idle-disconnect (
idleTimeoutMinutes): both share the same emptiness signal but act independently (pause immediately; disconnect after N minutes).
Current state (verified)
client.tsgetClientsInChannel()returns all clients in the bot's channel including the bot; callers compute "others" aslength - 1. No persistent roster.- The library emits
clientEnter/clientLeave/clientMoved;client.tscurrently only logsclientEnterand does not re-emit leave/moved. - The idle poller in
instance.ts(_startIdlePoller, every 30s) already computesuserCount = getClientsInChannel().length - 1and, when<= 0, schedules an idle-disconnect afteridleTimeoutMinutes. player.pause()/player.resume()already pause/resume without disconnecting (ffmpeg stays alive, no voice sent). The player only knowsidle|playing|paused— there is no auto-vs-user-pause distinction today.BotConfig.autoPauseOnEmptyexists (default true) but is read nowhere.
Design
Occupancy signal (shared)
Extract the idle poller's count into one method on BotInstance:
checkChannelOccupancy() → queries getClientsInChannel(), computes userCount = length - 1,
and drives both the existing idle-disconnect timer (unchanged behavior) and the new
auto-pause logic below. It is called by:
- the existing 30s poll (fallback), and
- new TS event handlers.
Event subscription
client.ts: subscribe to and re-emit clientEnter, clientLeave, clientMoved up to
BotInstance. BotInstance.setupTsEvents() calls checkChannelOccupancy() on each (a re-query
is simplest, since clientLeave carries no channel id). This gives near-instant pause/resume;
the poll remains as a safety net.
Auto-pause logic (inside checkChannelOccupancy)
Add a private autoPaused = false flag to BotInstance.
- Empty (
userCount <= 0): ifconfig.autoPauseOnEmptyandplayer.getState() === "playing"→player.pause(),autoPaused = true, emitstateChange. (Idle-disconnect timer still scheduled as today.) - Re-populated (
userCount > 0): ifautoPausedandplayer.getState() === "paused"→player.resume(),autoPaused = false, emitstateChange. (Idle timer cancelled as today.)
autoPaused bookkeeping (so user pauses are respected)
Clear autoPaused = false in cmdPause, cmdResume, cmdStop, cmdPlay, and on
connect/disconnect (the disconnected handler calls player.stop() → idle). Net effect: only a
track we auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
Config wiring
GET /api/bot/settings: includeautoPauseOnEmptyin the payload (alongsideidleTimeoutMinutes).POST /api/bot/settings: accept + validate a booleanautoPauseOnEmpty,saveConfig, and propagate to live bots via a newBotInstance.updateAutoPause(enabled)(mirrorsupdateIdleTimeout). Since the instance readsthis.config.autoPauseOnEmptylive, propagation can be as simple as updating the stored config reference / a field the check reads.- Frontend
Settings.vue→ the 行为设置 section (alreadybot.manage-gated): add a toggle forautoPauseOnEmptynext to the idle-timeout control; load it in the settings fetch and send it on save.
Components / files
src/ts-protocol/client.ts— subscribe + re-emitclientEnter/clientLeave/clientMoved.src/bot/instance.ts—autoPausedfield;checkChannelOccupancy()(refactored from the idle poller, drives idle + auto-pause); event handlers; clearautoPausedin user commands + connect/disconnect;updateAutoPause(enabled).src/web/api/bot.ts—GET/POST /settingshandleautoPauseOnEmpty.web/src/views/Settings.vue(+ player store settings load/save) — the toggle.src/data/config.ts— field already exists (no change beyond confirming default).
Testing
- Decision unit test (TDD): extract the pause/resume decision into a testable method, e.g.
applyOccupancy(userCount)operating on an injected fake player (getState/pause/resume)- the
autoPausedflag + the config flag. Cases: empty+playing+enabled → pause +autoPaused; re-populated+autoPaused+paused → resume + clear; re-populated when NOTautoPaused(user pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
- the
- API test:
GET/POST /api/bot/settingsround-tripsautoPauseOnEmpty(validates boolean, persists, propagates). - Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
Non-goals
- 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); 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()insrc/data/config.tsand the rationale comment there. - Query commands are unusable when others are present.
clientlist,channellist, andchannelclientlistALL 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. SogetClientsInChannel()returns[]exactly when occupancy matters, andoccupancyFromClientList(0)returnsnull("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
clientEnterpush event (shouldResumeOnReturn()+_resumeIfReturning()ininstance.ts), NOT from a query. Because the bot only auto-pauses while alone, the sole way occupancy can return whileautoPausedis set is a fresh connection — delivered asclientEnter. 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 always0(library reads notify paramcidbut enter-view carriesctid), andclientMoveddelivery is flaky. Do NOT attempt to layerclientMoved.targetChannelIDchannel-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.vuewas updated to say "服务器" rather than "频道" to match.cmdVotewas intentionally left on the query path (out of scope; switching it would inherit the same timeout).