From d3fd547ea07ccd87b80b11f8d48822faf8c33e3c Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 16 Jun 2026 23:55:44 +0800 Subject: [PATCH] fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 8 ++++---- src/bot/auto-pause.test.ts | 20 +++++++++++++++++++- src/bot/auto-pause.ts | 19 +++++++++++++++++++ src/bot/instance.ts | 13 +++++++++---- src/data/config.test.ts | 3 ++- src/data/config.ts | 5 ++++- 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4b984a0..b269633 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 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/` 打开时锁定到单台机器人,`?bot=` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。 -- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认开启)。 +- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。 - **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。 **Bug 修复** diff --git a/src/bot/auto-pause.test.ts b/src/bot/auto-pause.test.ts index 15ab59e..6d383ac 100644 --- a/src/bot/auto-pause.test.ts +++ b/src/bot/auto-pause.test.ts @@ -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(); + }); +}); diff --git a/src/bot/auto-pause.ts b/src/bot/auto-pause.ts index 60f1223..b33fe88 100644 --- a/src/bot/auto-pause.ts +++ b/src/bot/auto-pause.ts @@ -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. diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 8931c98..cdaecda 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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); }; diff --git a/src/data/config.test.ts b/src/data/config.test.ts index a49875f..5e81537 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -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 --- diff --git a/src/data/config.ts b/src/data/config.ts index 301c6ed..80aed56 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -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,