fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF

Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.

Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.

Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.

Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.

Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-16 23:55:44 +08:00
1 parent 75f09694a3
commit d3fd547ea0
6 files changed
+57 -11

No files matched your search

+4 -4
View File
@@ -26,7 +26,7 @@
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(可在设置中关闭)
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
@@ -484,7 +484,7 @@ pip install -U yt-dlp
"adminPassword": "",
"adminGroups": [],
"autoReturnDelay": 300,
"autoPauseOnEmpty": true,
"autoPauseOnEmpty": false,
"idleTimeoutMinutes": 0,
"publicUrl": "",
"trustProxy": false
@@ -560,7 +560,7 @@ A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
**Q:机器人播放时突然自动暂停了?**
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。可在 **设置 → 行为设置** 关闭"频道无人时自动暂停"。
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
@@ -590,7 +590,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认开启)。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
**Bug 修复**
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { decideOccupancyAction } from "./auto-pause.js";
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
it("pauses when empty while playing and enabled", () => {
@@ -27,3 +27,21 @@ describe("decideOccupancyAction", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
describe("occupancyFromClientList", () => {
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
// return [], which must be treated as "unknown", NOT as an empty channel.
expect(occupancyFromClientList(0)).toBeNull();
});
it("returns 0 other users when only the bot is in the channel", () => {
expect(occupancyFromClientList(1)).toBe(0);
});
it("excludes the bot itself from the count", () => {
expect(occupancyFromClientList(2)).toBe(1);
expect(occupancyFromClientList(5)).toBe(4);
});
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
expect(occupancyFromClientList(-3)).toBeNull();
});
});
+19
View File
@@ -1,6 +1,25 @@
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Convert a channel client-list length into the number of *other* users, or
* `null` when occupancy can't be determined.
*
* A connected bot is always a member of its own channel, so a valid query
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
* full-client `clientlist` command times out when other clients are present,
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
* "empty" is what caused playback to auto-pause within seconds whenever a
* listener was actually in the channel. When the result is indeterminate we
* return `null` so callers skip the auto-pause/idle decision entirely rather
* than mis-reading an unknown state as empty.
*/
export function occupancyFromClientList(clientCount: number): number | null {
if (clientCount <= 0) return null; // query failed → occupancy unknown
return clientCount - 1; // exclude the bot itself
}
/**
* Decide what auto-pause should do given channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
+9 -4
View File
@@ -18,7 +18,7 @@ import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
import { decideOccupancyAction } from "./auto-pause.js";
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
export interface BotInstanceOptions {
id: string;
@@ -175,7 +175,11 @@ export class BotInstance extends EventEmitter {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
this.handleOccupancy(clients.length - 1);
// A 0-length result means the clientlist query failed (the bot is always
// in its own channel) — occupancy is unknown, so don't act. Acting on it
// would mis-read it as "empty" and falsely auto-pause / idle-disconnect.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch {
// ignore — the 30s poll is the fallback
}
@@ -229,8 +233,9 @@ export class BotInstance extends EventEmitter {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
this.handleOccupancy(userCount);
// null = clientlist query failed (occupancy unknown) → don't act.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
+2 -1
View File
@@ -49,7 +49,8 @@ describe("config", () => {
// defaults should fill in the rest
expect(loaded.theme).toBe("dark");
expect(loaded.commandPrefix).toBe("!");
expect(loaded.autoPauseOnEmpty).toBe(true);
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
expect(loaded.autoPauseOnEmpty).toBe(false);
});
// --- #86: config.json must live under (and be created in) the persisted data dir ---
+4 -1
View File
@@ -36,7 +36,10 @@ export function getDefaultConfig(): BotConfig {
adminPassword: "",
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
// Default OFF: occupancy detection relies on the full-client `clientlist`
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,