Files
teamspeak-music-bot/docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md
T
saopig1andClaude Opus 4.8 3a34c01abb fix(auto-pause): auto-resume on a listener's return via clientEnter event
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>
2026-06-17 23:11:43 +08:00

8.0 KiB
Raw Blame History

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, default true). 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.ts getClientsInChannel() returns all clients in the bot's channel including the bot; callers compute "others" as length - 1. No persistent roster.
  • The library emits clientEnter / clientLeave / clientMoved; client.ts currently only logs clientEnter and does not re-emit leave/moved.
  • The idle poller in instance.ts (_startIdlePoller, every 30s) already computes userCount = getClientsInChannel().length - 1 and, when <= 0, schedules an idle-disconnect after idleTimeoutMinutes.
  • player.pause() / player.resume() already pause/resume without disconnecting (ffmpeg stays alive, no voice sent). The player only knows idle|playing|paused — there is no auto-vs-user-pause distinction today.
  • BotConfig.autoPauseOnEmpty exists (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:

  1. the existing 30s poll (fallback), and
  2. 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): if config.autoPauseOnEmpty and player.getState() === "playing" → player.pause(), autoPaused = true, emit stateChange. (Idle-disconnect timer still scheduled as today.)
  • Re-populated (userCount > 0): if autoPaused and player.getState() === "paused" → player.resume(), autoPaused = false, emit stateChange. (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: include autoPauseOnEmpty in the payload (alongside idleTimeoutMinutes).
  • POST /api/bot/settings: accept + validate a boolean autoPauseOnEmpty, saveConfig, and propagate to live bots via a new BotInstance.updateAutoPause(enabled) (mirrors updateIdleTimeout). Since the instance reads this.config.autoPauseOnEmpty live, propagation can be as simple as updating the stored config reference / a field the check reads.
  • Frontend Settings.vue → the 行为设置 section (already bot.manage-gated): add a toggle for autoPauseOnEmpty next 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-emit clientEnter/clientLeave/clientMoved.
  • src/bot/instance.ts — autoPaused field; checkChannelOccupancy() (refactored from the idle poller, drives idle + auto-pause); event handlers; clear autoPaused in user commands + connect/disconnect; updateAutoPause(enabled).
  • src/web/api/bot.ts — GET/POST /settings handle autoPauseOnEmpty.
  • 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 autoPaused flag + the config flag. Cases: empty+playing+enabled → pause + autoPaused; re-populated+autoPaused+paused → resume + clear; re-populated when NOT autoPaused (user pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
  • API test: GET/POST /api/bot/settings round-trips autoPauseOnEmpty (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() 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).