2 Commits
Author SHA1 Message Date
ddaodan 2d81615f78 优化自动每日指令与扫码退出秘境 2026-08-30 00:40:18 +08:00
ddaodan da8a82bfd5 修复直播测试与 TTS 预热 2026-08-17 15:44:23 +08:00
16 changed files with 1165 additions and 714 deletions
+6
View File
@@ -2,6 +2,7 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv/
.venv-tts/
runtime/ runtime/
config/.venv_python_path.txt config/.venv_python_path.txt
@@ -21,6 +22,10 @@ data/queue_state.json
data/song_requests.json data/song_requests.json
data/tts_state.json data/tts_state.json
data/bilibili_credentials.json data/bilibili_credentials.json
data/admin_audit.log
data/admin_auth.json
data/statistics.sqlite3*
data/users.json
web/music_cover.jpg web/music_cover.jpg
# IDE # IDE
@@ -30,3 +35,4 @@ web/music_cover.jpg
# Large binaries (kept local only) # Large binaries (kept local only)
vendor/mpv/mpv.exe vendor/mpv/mpv.exe
vendor/mpv/mpv.7z vendor/mpv/mpv.7z
vendor/tts-model/
+3 -3
View File
@@ -106,9 +106,9 @@ npm run build
| `confirm_yes` | 是 | 确认账号正确 | | `confirm_yes` | 是 | 确认账号正确 |
| `confirm_no` | 不是 | 确认账号不正确,重新扫码 | | `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) | | `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
| `daily` | 自动每日 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 | | `daily` | 自动每日, 每日, 日常, 做每日, 做日常 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 |
| `switch_party` | 切换队伍, 更换队伍 | 修改并执行配置组“切换队伍” | | `switch_party` | 切换队伍, 更换队伍, 换队伍, 换队 | 修改并执行配置组“切换队伍” |
| `edit_party` | 修改队员, 更换队员 | 校验四名角色后修改并执行配置组“修改队员” | | `edit_party` | 修改队员, 更换队员, 换队员, 换角色 | 校验四名角色后修改并执行配置组“修改队员” |
| `leave` | 退出 | 退出队列 | | `leave` | 退出 | 退出队列 |
| `reset` | 重置 | 一级用户重启原神和 BGI | | `reset` | 重置 | 一级用户重启原神和 BGI |
| `points` | 积分 | 查询积分 | | `points` | 积分 | 查询积分 |
+392 -36
View File
@@ -61,6 +61,10 @@ from pathlib import Path
from typing import Any from typing import Any
MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK" MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK"
MANUAL_LIVE_TEST_DURATION_SECONDS = 30 * 60
BILIBILI_LIVE_STATUS_POLL_TIMEOUT_SECONDS = 45.0
BILIBILI_LIVE_STATUS_POLL_INTERVAL_SECONDS = 2.0
BILIBILI_LIVE_STATUS_REFRESH_SECONDS = 60.0
def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]: def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]:
@@ -247,6 +251,72 @@ COMMAND_ALLOWED_ROLE_DEFAULTS = {
"queue_list": ALL_DANMU_ROLES, "queue_list": ALL_DANMU_ROLES,
"help": ALL_DANMU_ROLES, "help": ALL_DANMU_ROLES,
} }
NEW_COMMAND_ALIAS_DEFAULTS = {
"daily": ["自动每日", "每日", "日常", "做每日", "做日常"],
"switch_party": ["切换队伍", "更换队伍", "换队伍", "换队"],
"edit_party": ["修改队员", "更换队员", "换队员", "换角色"],
}
NEW_COMMAND_PREFERRED_ALIASES = {
"daily": "每日",
"switch_party": "换队",
"edit_party": "换角色",
}
FRONTEND_COMMAND_HINT_KEYS = tuple(NEW_COMMAND_ALIAS_DEFAULTS)
def configured_command_aliases(
config_data: dict[str, Any],
command_key: str,
fallback: list[str] | tuple[str, ...] = (),
) -> list[str]:
commands = config_data.get("commands", {})
cfg = commands.get(command_key, {}) if isinstance(commands, dict) else {}
if not isinstance(cfg, dict) or not cfg.get("enabled", True):
return []
source = cfg.get("aliases")
if not isinstance(source, list):
source = list(fallback)
aliases: list[str] = []
for item in source:
alias = str(item).strip()
if alias and alias not in aliases:
aliases.append(alias)
return aliases
def preferred_command_alias(
config_data: dict[str, Any],
command_key: str,
fallback: str,
) -> str:
aliases = configured_command_aliases(config_data, command_key, (fallback,))
preferred = NEW_COMMAND_PREFERRED_ALIASES.get(command_key, "")
if preferred in aliases:
return preferred
return min(aliases, key=len) if aliases else fallback
def build_public_command_hints(config_data: dict[str, Any]) -> dict[str, dict[str, Any]]:
commands = config_data.get("commands", {})
hints: dict[str, dict[str, Any]] = {}
for command_key in FRONTEND_COMMAND_HINT_KEYS:
cfg = commands.get(command_key, {}) if isinstance(commands, dict) else {}
enabled = isinstance(cfg, dict) and bool(cfg.get("enabled", True))
aliases = configured_command_aliases(
config_data,
command_key,
NEW_COMMAND_ALIAS_DEFAULTS[command_key],
)
hints[command_key] = {
"enabled": enabled,
"aliases": aliases,
"preferred_alias": preferred_command_alias(
config_data,
command_key,
NEW_COMMAND_PREFERRED_ALIASES[command_key],
) if aliases else "",
}
return hints
def normalize_allowed_roles(value: Any, default: list[str]) -> list[str]: def normalize_allowed_roles(value: Any, default: list[str]) -> list[str]:
@@ -511,6 +581,7 @@ class Config:
tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8) tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8)
tts_all["faster-qwen3-tts"].setdefault("append_silence", True) tts_all["faster-qwen3-tts"].setdefault("append_silence", True)
tts_all["faster-qwen3-tts"].setdefault("streaming", True) tts_all["faster-qwen3-tts"].setdefault("streaming", True)
tts_all["faster-qwen3-tts"].setdefault("startup_timeout_seconds", 900)
self.data.setdefault("frontend", {}) self.data.setdefault("frontend", {})
if legacy_frontend: if legacy_frontend:
self.data["frontend"].update(legacy_frontend) self.data["frontend"].update(legacy_frontend)
@@ -623,9 +694,9 @@ class Config:
"confirm_yes": {"enabled": True, "aliases": [""]}, "confirm_yes": {"enabled": True, "aliases": [""]},
"confirm_no": {"enabled": True, "aliases": ["不是"]}, "confirm_no": {"enabled": True, "aliases": ["不是"]},
"run": {"enabled": True, "aliases": ["执行", "", "开始"]}, "run": {"enabled": True, "aliases": ["执行", "", "开始"]},
"daily": {"enabled": True, "aliases": ["自动每日"]}, "daily": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["daily"])},
"switch_party": {"enabled": True, "aliases": ["切换队伍", "更换队伍"]}, "switch_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["switch_party"])},
"edit_party": {"enabled": True, "aliases": ["修改队员", "更换队员"]}, "edit_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["edit_party"])},
"leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]}, "leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]},
"reset": {"enabled": True, "aliases": ["重置"]}, "reset": {"enabled": True, "aliases": ["重置"]},
"points": {"enabled": True, "aliases": ["积分"]}, "points": {"enabled": True, "aliases": ["积分"]},
@@ -2450,6 +2521,10 @@ class FasterQwen3TTSEngine:
self.cpu_threads = max(1, min(8, int(self.cfg.get("cpu_threads", 4) or 4))) self.cpu_threads = max(1, min(8, int(self.cfg.get("cpu_threads", 4) or 4)))
self.cpu_affinity_count = max(0, int(self.cfg.get("cpu_affinity_count", 8) or 0)) self.cpu_affinity_count = max(0, int(self.cfg.get("cpu_affinity_count", 8) or 0))
self.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal") self.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal")
self.startup_timeout_seconds = max(
60,
int(self.cfg.get("startup_timeout_seconds", 900) or 900),
)
self.logger = logger self.logger = logger
self._parent = parent self._parent = parent
self._worker_ready = False self._worker_ready = False
@@ -2473,6 +2548,7 @@ class FasterQwen3TTSEngine:
}, },
logger, logger,
synthesis_timeout_seconds=120, synthesis_timeout_seconds=120,
startup_timeout_seconds=self.startup_timeout_seconds,
) )
if bool(self.cfg.get("streaming", False)): if bool(self.cfg.get("streaming", False)):
self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true") self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true")
@@ -2720,6 +2796,7 @@ class Broadcaster:
self._tts_sequence = 0 self._tts_sequence = 0
self._tts_started = False self._tts_started = False
self._tts_warmup_done = asyncio.Event() self._tts_warmup_done = asyncio.Event()
self._tts_warmup_task: asyncio.Task | None = None
self._broadcast_channels: dict[str, dict[str, str]] = {} self._broadcast_channels: dict[str, dict[str, str]] = {}
self._stop = False self._stop = False
# 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送 # 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送
@@ -2762,9 +2839,14 @@ class Broadcaster:
self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭") self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭")
self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}") self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}")
async def _warmup_tts_in_background(self, tts: TTSEngine):
try:
await tts.warmup()
finally:
self._tts_warmup_done.set()
async def start(self): async def start(self):
if self._tts_started: if self._tts_started:
await self._tts_warmup_done.wait()
return return
self._tts_started = True self._tts_started = True
if not self.tts.enabled: if not self.tts.enabled:
@@ -2777,10 +2859,16 @@ class Broadcaster:
for task in (synth_task, play_task): for task in (synth_task, play_task):
task.add_done_callback(self._tts_worker_tasks.discard) task.add_done_callback(self._tts_worker_tasks.discard)
try: if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
if bool(self._tts_queue_cfg.get("warmup_on_start", True)): warmup_task = asyncio.create_task(
await self.tts.warmup() self._warmup_tts_in_background(self.tts),
finally: name="TTS后台预热",
)
self._tts_warmup_task = warmup_task
self._tts_worker_tasks.add(warmup_task)
warmup_task.add_done_callback(self._tts_worker_tasks.discard)
self.logger.info("[TTS队列] 后台预热已启动,不阻塞弹幕监听")
else:
self._tts_warmup_done.set() self._tts_warmup_done.set()
self.logger.info( self.logger.info(
f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, " f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, "
@@ -5285,6 +5373,11 @@ class CommandHandler:
) )
def _is_live_time(self) -> bool: def _is_live_time(self) -> bool:
if self.system and (
self.system.manual_live_override_active()
or self.system.bilibili_live_active()
):
return True
return is_within_live_time(self.config.system_cfg) return is_within_live_time(self.config.system_cfg)
def _rule_matches(self, rule: dict, text: str) -> bool: def _rule_matches(self, rule: dict, text: str) -> bool:
@@ -5790,7 +5883,22 @@ class CommandHandler:
if answer == "": if answer == "":
self.queue_mgr.set_login_status("logged_in") self.queue_mgr.set_login_status("logged_in")
self.logger.info(f"[登录确认] {uname}({uid}) 确认账号正确,升为二级") self.logger.info(f"[登录确认] {uname}({uid}) 确认账号正确,升为二级")
await self.broadcast(f"{uname}」账号已确认,发送\"执行 组名\"开始(如: 执行 泡泡桔)") daily_alias = preferred_command_alias(self.config.data, "daily", "自动每日")
switch_alias = preferred_command_alias(self.config.data, "switch_party", "切换队伍")
edit_alias = preferred_command_alias(self.config.data, "edit_party", "修改队员")
run_aliases = configured_command_aliases(self.config.data, "run", ("执行",))
run_alias = run_aliases[0] if run_aliases else "执行"
await self.broadcast(
f"{uname}」账号已确认,可发送“{daily_alias}”完成基础每日",
tts=True,
)
await self.broadcast(
f"其他模式:{daily_alias} 秘境 风本 / {daily_alias} 地脉 经验 蒙德 / "
f"{daily_alias} 委托;{switch_alias} 永冻队 / {edit_alias} 四名角色;"
f"普通任务仍发送“{run_alias} 组名”",
tts=False,
danmu=True,
)
return return
self.logger.info(f"[登录确认] {uname}({uid}) 确认不是本人账号,重新扫码上号") self.logger.info(f"[登录确认] {uname}({uid}) 确认不是本人账号,重新扫码上号")
await self.broadcast(f"{uname}」账号不正确,正在关闭当前账号并重新扫码上号", tts=True) await self.broadcast(f"{uname}」账号不正确,正在关闭当前账号并重新扫码上号", tts=True)
@@ -6509,12 +6617,8 @@ class CommandHandler:
def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str: def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str:
"""获取指令的别名展示字符串,用于帮助信息。""" """获取指令的别名展示字符串,用于帮助信息。"""
cmds = self.config.data.get("commands", {}) aliases = configured_command_aliases(self.config.data, key, (fallback,))
cfg = cmds.get(key, {}) return "/".join(aliases) if aliases else fallback
if cfg.get("enabled", True):
aliases = cfg.get("aliases", [])
return "/".join(str(a).strip() for a in aliases if str(a).strip())
return fallback
async def _cmd_help(self, uid: int, uname: str): async def _cmd_help(self, uid: int, uname: str):
q = self._get_cmd_aliases_str("queue", "排队") q = self._get_cmd_aliases_str("queue", "排队")
@@ -6867,6 +6971,23 @@ def get_real_room_id(room_id: int, cookie: str = "") -> int:
return real_room_id return real_room_id
def get_bilibili_live_status(room_id: int, cookie: str = "") -> int:
"""返回 B站房间直播状态:0=未开播,1=直播中,其他值按接口原样返回。"""
data = _request_bilibili_json(
f"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={int(room_id)}",
cookie,
f"https://live.bilibili.com/{int(room_id)}",
)
if data.get("code") != 0:
raise RuntimeError(
f"查询直播状态失败: code={data.get('code')} message={data.get('message', '')}"
)
try:
return int((data.get("data") or {}).get("live_status", -1))
except (TypeError, ValueError) as exc:
raise RuntimeError("直播状态接口未返回有效 live_status") from exc
def get_danmu_server(room_id: int, cookie: str = "") -> dict: def get_danmu_server(room_id: int, cookie: str = "") -> dict:
"""使用登录 Cookie 获取弹幕服务器和专用 tokentoken 不得回退为 SESSDATA。""" """使用登录 Cookie 获取弹幕服务器和专用 tokentoken 不得回退为 SESSDATA。"""
url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={int(room_id)}" url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={int(room_id)}"
@@ -7241,6 +7362,9 @@ class SystemScheduler:
self._live_session_started_at: datetime | None = None self._live_session_started_at: datetime | None = None
self._startup_compensation_task: asyncio.Task | None = None self._startup_compensation_task: asyncio.Task | None = None
self._startup_compensation_window_key: str | None = None self._startup_compensation_window_key: str | None = None
self._last_live_action_error = ""
self._last_bilibili_live_status: int | None = None
self._last_live_status_refresh_monotonic = 0.0
@property @property
def cfg(self) -> dict: def cfg(self) -> dict:
@@ -7452,6 +7576,89 @@ class SystemScheduler:
user32.EnumWindows(EnumWindowsProc(callback), 0) user32.EnumWindows(EnumWindowsProc(callback), 0)
return hwnd_found[0] if hwnd_found else None return hwnd_found[0] if hwnd_found else None
@property
def last_live_action_error(self) -> str:
return self._last_live_action_error
@staticmethod
def _force_foreground_window(hwnd: int) -> bool:
"""在同一登录会话中临时合并输入线程,可靠地前置目标窗口。"""
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
current_thread = int(kernel32.GetCurrentThreadId())
foreground = int(user32.GetForegroundWindow() or 0)
target_pid = ctypes.wintypes.DWORD()
target_thread = int(user32.GetWindowThreadProcessId(hwnd, ctypes.byref(target_pid)) or 0)
foreground_thread = 0
if foreground:
foreground_pid = ctypes.wintypes.DWORD()
foreground_thread = int(
user32.GetWindowThreadProcessId(foreground, ctypes.byref(foreground_pid)) or 0
)
attached: list[int] = []
try:
for thread_id in {target_thread, foreground_thread}:
if thread_id and thread_id != current_thread:
if user32.AttachThreadInput(current_thread, thread_id, True):
attached.append(thread_id)
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
else:
user32.ShowWindow(hwnd, 5) # SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
user32.SetActiveWindow(hwnd)
user32.SetFocus(hwnd)
finally:
for thread_id in reversed(attached):
user32.AttachThreadInput(current_thread, thread_id, False)
return int(user32.GetForegroundWindow() or 0) == int(hwnd)
async def _get_bilibili_live_status(self, *, record_error: bool = True) -> int | None:
try:
status = await asyncio.to_thread(
get_bilibili_live_status,
int(self.config.room_id),
self.config.bilibili_cookie,
)
self._last_bilibili_live_status = status
if self.system:
self.system.set_bilibili_live_status(status)
return status
except Exception as exc:
message = f"查询 B站直播状态失败: {exc}"
if record_error:
self._last_live_action_error = message
self.logger.warning(f"[系统定时] {message}")
else:
self.logger.debug(f"[系统定时] {message}")
return None
async def _wait_for_bilibili_live_status(
self,
expected_status: int,
*,
timeout_seconds: float = BILIBILI_LIVE_STATUS_POLL_TIMEOUT_SECONDS,
) -> bool:
effective_timeout = max(1.0, float(timeout_seconds))
deadline = time.monotonic() + effective_timeout
while True:
status = await self._get_bilibili_live_status()
if status == expected_status:
self._last_live_action_error = ""
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
state_name = "开播" if expected_status == 1 else "关播"
self._last_live_action_error = (
f"已点击直播姬,但 B站在 {int(effective_timeout)} 秒内未确认{state_name}"
f"live_status={status}"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
await asyncio.sleep(min(BILIBILI_LIVE_STATUS_POLL_INTERVAL_SECONDS, remaining))
async def _click_bilibili_live( async def _click_bilibili_live(
self, self,
*, *,
@@ -7460,57 +7667,137 @@ class SystemScheduler:
action_name: str, action_name: str,
confirm_enter: bool = False, confirm_enter: bool = False,
) -> bool: ) -> bool:
"""直播姬窗口前置,在指定比例坐标点击;可选按 Enter 确认弹窗""" """确认直播姬获得前台后,在恢复后的窗口坐标点击。"""
self._last_live_action_error = ""
if os.name != "nt": if os.name != "nt":
self.logger.warning(f"[系统定时] 当前系统不支持自动{action_name}") self._last_live_action_error = f"当前系统不支持自动{action_name}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
keyword = self.cfg.get("bilibili_push_window_keyword", "直播姬") keyword = self.cfg.get("bilibili_push_window_keyword", "直播姬")
hwnd = self._find_window_by_keyword(keyword) hwnd = self._find_window_by_keyword(keyword)
if not hwnd: if not hwnd:
self.logger.warning(f"[系统定时] 未找到直播姬窗口,关键字: {keyword}") self._last_live_action_error = f"未找到直播姬窗口,关键字: {keyword}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
rect = ctypes.wintypes.RECT() previous_dpi_context = None
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)): set_thread_dpi_context = getattr(user32, "SetThreadDpiAwarenessContext", None)
self.logger.warning("[系统定时] 获取直播姬窗口位置失败")
return False
x_ratio = float(self.cfg.get(x_ratio_key, 0.741))
y_ratio = float(self.cfg.get(y_ratio_key, 0.907))
x = int(rect.left + max(0.0, min(1.0, x_ratio)) * (rect.right - rect.left))
y = int(rect.top + max(0.0, min(1.0, y_ratio)) * (rect.bottom - rect.top))
try: try:
user32.ShowWindow(hwnd, 9) # SW_RESTORE if set_thread_dpi_context:
user32.SetForegroundWindow(hwnd) set_thread_dpi_context.restype = ctypes.c_void_p
previous_dpi_context = set_thread_dpi_context(ctypes.c_void_p(-4))
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
else:
user32.ShowWindow(hwnd, 5) # SW_SHOW
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
user32.SetCursorPos(x, y) if not self._force_foreground_window(hwnd):
await asyncio.sleep(0.3)
if int(user32.GetForegroundWindow() or 0) != int(hwnd):
self._last_live_action_error = "直播姬窗口无法获得前台,已取消点击以避免误操作其他窗口"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
rect = ctypes.wintypes.RECT()
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
self._last_live_action_error = "获取直播姬窗口位置失败"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
width = int(rect.right - rect.left)
height = int(rect.bottom - rect.top)
if width < 200 or height < 150:
self._last_live_action_error = (
f"直播姬窗口尺寸异常: rect=({rect.left},{rect.top},{rect.right},{rect.bottom})"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
x_ratio = float(self.cfg.get(x_ratio_key, 0.741))
y_ratio = float(self.cfg.get(y_ratio_key, 0.907))
x = int(rect.left + max(0.0, min(1.0, x_ratio)) * width)
y = int(rect.top + max(0.0, min(1.0, y_ratio)) * height)
virtual_left = int(user32.GetSystemMetrics(76))
virtual_top = int(user32.GetSystemMetrics(77))
virtual_right = virtual_left + int(user32.GetSystemMetrics(78))
virtual_bottom = virtual_top + int(user32.GetSystemMetrics(79))
if not (virtual_left <= x < virtual_right and virtual_top <= y < virtual_bottom):
self._last_live_action_error = (
f"直播姬点击坐标超出屏幕: x={x}, y={y}, "
f"screen=({virtual_left},{virtual_top},{virtual_right},{virtual_bottom})"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
previous_cursor = ctypes.wintypes.POINT()
has_previous_cursor = bool(user32.GetCursorPos(ctypes.byref(previous_cursor)))
if not user32.SetCursorPos(x, y):
self._last_live_action_error = (
f"无法移动鼠标到直播姬按钮位置: x={x}, y={y}"
"请通过桌面上可见的 run.bat 控制台启动服务后重试"
)
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if int(user32.GetForegroundWindow() or 0) != int(hwnd):
self._last_live_action_error = "点击前直播姬失去前台,已取消操作"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
if has_previous_cursor:
user32.SetCursorPos(previous_cursor.x, previous_cursor.y)
return False
user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP
if has_previous_cursor:
user32.SetCursorPos(previous_cursor.x, previous_cursor.y)
if confirm_enter: if confirm_enter:
await asyncio.sleep(0.8) await asyncio.sleep(0.8)
user32.keybd_event(0x0D, 0, 0, 0) # VK_RETURN key down user32.keybd_event(0x0D, 0, 0, 0) # VK_RETURN key down
user32.keybd_event(0x0D, 0, 0x0002, 0) # key up user32.keybd_event(0x0D, 0, 0x0002, 0) # key up
self.logger.info(f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}") self.logger.info(
f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}, "
f"rect=({rect.left},{rect.top},{rect.right},{rect.bottom})"
)
return True return True
except Exception as e: except Exception as e:
self.logger.warning(f"[系统定时] 点击直播姬{action_name}失败: {e}") self._last_live_action_error = f"点击直播姬{action_name}失败: {e}"
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
return False return False
finally:
if set_thread_dpi_context and previous_dpi_context:
try:
set_thread_dpi_context(ctypes.c_void_p(previous_dpi_context))
except Exception:
pass
async def push_bilibili_live(self) -> bool: async def push_bilibili_live(self) -> bool:
return await self._click_bilibili_live( self._last_live_action_error = ""
if await self._get_bilibili_live_status() == 1:
self.logger.info("[系统定时] B站已处于开播状态,跳过重复点击")
return True
clicked = await self._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio", x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio", y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮", action_name="开启推流按钮",
) )
if not clicked:
return False
return await self._wait_for_bilibili_live_status(1)
async def stop_bilibili_live(self) -> bool: async def stop_bilibili_live(self) -> bool:
return await self._click_bilibili_live( self._last_live_action_error = ""
if await self._get_bilibili_live_status() == 0:
self.logger.info("[系统定时] B站已处于未开播状态,跳过重复关播点击")
return True
clicked = await self._click_bilibili_live(
x_ratio_key="bilibili_stop_push_click_x_ratio", x_ratio_key="bilibili_stop_push_click_x_ratio",
y_ratio_key="bilibili_stop_push_click_y_ratio", y_ratio_key="bilibili_stop_push_click_y_ratio",
action_name="关闭推流按钮", action_name="关闭推流按钮",
confirm_enter=bool(self.cfg.get("bilibili_stop_push_confirm_enter", True)), confirm_enter=bool(self.cfg.get("bilibili_stop_push_confirm_enter", True)),
) )
if not clicked:
return False
return await self._wait_for_bilibili_live_status(0)
def _schedule_reboot_after_stop(self, stopped_at: datetime) -> bool: def _schedule_reboot_after_stop(self, stopped_at: datetime) -> bool:
if not self._is_enabled("reboot_after_stop_enabled"): if not self._is_enabled("reboot_after_stop_enabled"):
@@ -7677,6 +7964,13 @@ class SystemScheduler:
now = datetime.now().replace(second=0, microsecond=0) now = datetime.now().replace(second=0, microsecond=0)
today = date.today().isoformat() today = date.today().isoformat()
try: try:
monotonic_now = time.monotonic()
if (
monotonic_now - self._last_live_status_refresh_monotonic
>= BILIBILI_LIVE_STATUS_REFRESH_SECONDS
):
self._last_live_status_refresh_monotonic = monotonic_now
await self._get_bilibili_live_status(record_error=False)
for event, when in self._live_occurrences(now): for event, when in self._live_occurrences(now):
key = self._event_key(event, when) key = self._event_key(event, when)
if key in self._triggered_events or not self._event_due(now, when): if key in self._triggered_events or not self._event_due(now, when):
@@ -7744,6 +8038,11 @@ class QueueSystem:
self.stats_store = stats_store self.stats_store = stats_store
self.health = ServiceRegistry(stats_store) self.health = ServiceRegistry(stats_store)
self.live_client = None self.live_client = None
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
self._bilibili_live_status: int | None = None
self._bilibili_live_status_updated_monotonic = 0.0
self._bilibili_live_status_updated_wall = 0.0
self.health.set("主程序", ServiceRegistry.STARTING, "初始化") self.health.set("主程序", ServiceRegistry.STARTING, "初始化")
self.health.set("配置", ServiceRegistry.RUNNING, f"revision={config.revision}") self.health.set("配置", ServiceRegistry.RUNNING, f"revision={config.revision}")
self.user_mgr = UserManager( self.user_mgr = UserManager(
@@ -7792,6 +8091,54 @@ class QueueSystem:
self.handler.login_monitor = self.login_monitor self.handler.login_monitor = self.login_monitor
self.health.set("主程序", ServiceRegistry.RUNNING, "初始化完成") self.health.set("主程序", ServiceRegistry.RUNNING, "初始化完成")
def enable_manual_live_override(
self,
duration_seconds: int = MANUAL_LIVE_TEST_DURATION_SECONDS,
) -> float:
duration = max(60, min(int(duration_seconds), 6 * 60 * 60))
self._manual_live_override_until_monotonic = time.monotonic() + duration
self._manual_live_override_until_wall = time.time() + duration
self.logger.info(f"[直播测试] 已临时开放弹幕指令 {duration // 60} 分钟")
return self._manual_live_override_until_wall
def clear_manual_live_override(self) -> None:
was_active = self.manual_live_override_active()
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
if was_active:
self.logger.info("[直播测试] 已关闭手动直播指令时段")
def manual_live_override_active(self) -> bool:
if self._manual_live_override_until_monotonic <= time.monotonic():
self._manual_live_override_until_monotonic = 0.0
self._manual_live_override_until_wall = 0.0
return False
return True
def manual_live_override_snapshot(self) -> dict[str, Any]:
active = self.manual_live_override_active()
return {
"active": active,
"until": self._manual_live_override_until_wall if active else 0.0,
}
def set_bilibili_live_status(self, status: int) -> None:
self._bilibili_live_status = int(status)
self._bilibili_live_status_updated_monotonic = time.monotonic()
self._bilibili_live_status_updated_wall = time.time()
def bilibili_live_active(self, max_age_seconds: float = 120.0) -> bool:
status = getattr(self, "_bilibili_live_status", None)
updated = float(getattr(self, "_bilibili_live_status_updated_monotonic", 0.0) or 0.0)
return status == 1 and time.monotonic() - updated <= max(1.0, max_age_seconds)
def bilibili_live_status_snapshot(self) -> dict[str, Any]:
return {
"status": getattr(self, "_bilibili_live_status", None),
"updated_at": float(getattr(self, "_bilibili_live_status_updated_wall", 0.0) or 0.0),
"active": self.bilibili_live_active(),
}
def apply_config(self): def apply_config(self):
self.config.apply_runtime_settings() self.config.apply_runtime_settings()
self.user_mgr.config = self.config self.user_mgr.config = self.config
@@ -9223,6 +9570,8 @@ class WebServer:
"active_song_request": song_state.get("active"), "active_song_request": song_state.get("active"),
"recent_danmu": list(getattr(self.system.handler, "recent_danmu", [])[-25:]), "recent_danmu": list(getattr(self.system.handler, "recent_danmu", [])[-25:]),
"recent_gifts": list(getattr(self.system.handler, "recent_gifts", [])[-25:]), "recent_gifts": list(getattr(self.system.handler, "recent_gifts", [])[-25:]),
"manual_live_test": self.system.manual_live_override_snapshot(),
"bilibili_live": self.system.bilibili_live_status_snapshot(),
}) })
return state return state
@@ -9810,7 +10159,7 @@ class WebServer:
return web.Response(status=404, text="no upload") return web.Response(status=404, text="no upload")
async def _api_frontend_config(self, request): async def _api_frontend_config(self, request):
"""返回前台视觉配置。""" """返回前台视觉配置和可公开展示的指令提示"""
cfg = self.config.data.setdefault("frontend", {}) cfg = self.config.data.setdefault("frontend", {})
return web.json_response({ return web.json_response({
"background_image": cfg.get("background_image", ""), "background_image": cfg.get("background_image", ""),
@@ -9818,6 +10167,7 @@ class WebServer:
"background_blur": float(cfg.get("background_blur", 0)), "background_blur": float(cfg.get("background_blur", 0)),
"background_fit": cfg.get("background_fit", "cover"), "background_fit": cfg.get("background_fit", "cover"),
"theme": cfg.get("theme", "classic"), "theme": cfg.get("theme", "classic"),
"command_hints": build_public_command_hints(self.config.data),
}) })
async def _api_music_monitor_config(self, request): async def _api_music_monitor_config(self, request):
@@ -10284,13 +10634,19 @@ class WebServer:
elif action == "test_bilibili_push": elif action == "test_bilibili_push":
ok = await self.scheduler.push_bilibili_live() ok = await self.scheduler.push_bilibili_live()
if not ok: if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或点击失败"}, status=400) error = self.scheduler.last_live_action_error or "直播姬开播测试失败"
return web.json_response({"success": False, "error": error}, status=400)
manual_until = self.system.enable_manual_live_override()
audit_target = "bilibili_push" audit_target = "bilibili_push"
audit_detail = f"live confirmed manual_until={manual_until:.0f}"
elif action == "test_bilibili_stop_push": elif action == "test_bilibili_stop_push":
ok = await self.scheduler.stop_bilibili_live() ok = await self.scheduler.stop_bilibili_live()
if not ok: if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或关闭推流点击失败"}, status=400) error = self.scheduler.last_live_action_error or "直播姬关播测试失败"
return web.json_response({"success": False, "error": error}, status=400)
self.system.clear_manual_live_override()
audit_target = "bilibili_stop_push" audit_target = "bilibili_stop_push"
audit_detail = "offline confirmed"
elif action == "save_system_schedule_config": elif action == "save_system_schedule_config":
data = await request.json() data = await request.json()
self.config.reload() self.config.reload()
+112 -18
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import io import io
import multiprocessing import multiprocessing
import os import os
import sys
import threading import threading
import time import time
import traceback import traceback
@@ -13,13 +14,14 @@ from typing import Any, Callable
MAX_NEW_TOKENS = 384 MAX_NEW_TOKENS = 384
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0 DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.0 DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环 # 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。 # 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0 STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
DEFAULT_CPU_THREADS = 4 DEFAULT_CPU_THREADS = 4
DEFAULT_CPU_AFFINITY_COUNT = 8 DEFAULT_CPU_AFFINITY_COUNT = 8
DEFAULT_PROCESS_PRIORITY = "below_normal" DEFAULT_PROCESS_PRIORITY = "below_normal"
STARTUP_PROGRESS_POLL_SECONDS = 1.0
class FasterQwenWorkerError(RuntimeError): class FasterQwenWorkerError(RuntimeError):
@@ -122,15 +124,26 @@ def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
} }
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]: def _load_runtime(
settings: dict[str, Any],
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
def report(stage: str) -> None:
if progress:
progress(stage)
device = str(settings.get("device") or "cuda") device = str(settings.get("device") or "cuda")
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ: if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
os.environ["CUDA_VISIBLE_DEVICES"] = "" os.environ["CUDA_VISIBLE_DEVICES"] = ""
cpu_threads = _configure_worker_environment(settings) cpu_threads = _configure_worker_environment(settings)
report("torch_import_started")
import torch import torch
report("torch_import_complete")
_configure_torch_threads(torch, cpu_threads) _configure_torch_threads(torch, cpu_threads)
report("tts_library_import_started")
from faster_qwen3_tts import FasterQwen3TTS from faster_qwen3_tts import FasterQwen3TTS
report("tts_library_import_complete")
if device == "cpu": if device == "cpu":
torch.cuda.is_available = lambda: False torch.cuda.is_available = lambda: False
@@ -138,10 +151,12 @@ def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
load_kwargs: dict[str, Any] = {} load_kwargs: dict[str, Any] = {}
if device == "cpu": if device == "cpu":
load_kwargs["device"] = "cpu" load_kwargs["device"] = "cpu"
report("from_pretrained_started")
model = FasterQwen3TTS.from_pretrained( model = FasterQwen3TTS.from_pretrained(
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"), str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
**load_kwargs, **load_kwargs,
) )
report("from_pretrained_complete")
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有 # voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在 # create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
@@ -193,21 +208,63 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None: def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
def report(stage: str, **details: Any) -> None:
try:
connection.send({
"type": "progress",
"stage": stage,
"pid": os.getpid(),
**details,
})
except Exception:
pass
try: try:
report("process_started", executable=sys.executable, prefix=sys.prefix)
cpu_threads = _configure_worker_environment(settings) cpu_threads = _configure_worker_environment(settings)
process_limits = _apply_worker_process_limits(settings) report("environment_ready", cpu_threads=cpu_threads)
load_started = time.monotonic() load_started = time.monotonic()
runtime = _load_runtime(settings) report("model_loading", model_name=str(settings.get("model_name_or_path") or ""))
runtime = _load_runtime(settings, progress=report)
load_ms = int((time.monotonic() - load_started) * 1000) load_ms = int((time.monotonic() - load_started) * 1000)
report("model_loaded", load_ms=load_ms)
warmup_started = time.monotonic() warmup_started = time.monotonic()
graph_warmup_ms = 0
report("warmup_started")
warmup = getattr(runtime["model"], "warmup", None)
if callable(warmup):
graph_warmup_started = time.monotonic()
warmup()
graph_warmup_ms = int((time.monotonic() - graph_warmup_started) * 1000)
report("graph_warmup_complete", graph_warmup_ms=graph_warmup_ms)
# model.warmup() 只捕获模型内部 CUDA Graph,不会初始化参考音频和
# voice-clone 生成路径。若把这一步留到首条播报,后台会在近一分钟内
# 看起来毫无响应,因此在降低进程优先级之前完成一次真实短句合成。
voice_clone_warmup_started = time.monotonic()
report("voice_clone_warmup_started")
_synthesize_wav(runtime, "系统启动") _synthesize_wav(runtime, "系统启动")
voice_clone_warmup_ms = int((time.monotonic() - voice_clone_warmup_started) * 1000)
report("voice_clone_warmup_complete", voice_clone_warmup_ms=voice_clone_warmup_ms)
warmup_ms = int((time.monotonic() - warmup_started) * 1000) warmup_ms = int((time.monotonic() - warmup_started) * 1000)
report(
"warmup_complete",
warmup_ms=warmup_ms,
graph_warmup_ms=graph_warmup_ms,
voice_clone_warmup_ms=voice_clone_warmup_ms,
)
# 加载和 CUDA Graph 捕获需要完整 CPU 调度能力。仅在预热完成后降低
# worker 优先级和亲和性,避免繁忙直播环境下启动时间被放大到超时。
process_limits = _apply_worker_process_limits(settings)
connection.send({ connection.send({
"type": "ready", "type": "ready",
"pid": os.getpid(), "pid": os.getpid(),
"load_ms": load_ms, "load_ms": load_ms,
"warmup_ms": warmup_ms, "warmup_ms": warmup_ms,
"graph_warmup_ms": graph_warmup_ms,
"voice_clone_warmup_ms": voice_clone_warmup_ms,
"max_new_tokens": MAX_NEW_TOKENS, "max_new_tokens": MAX_NEW_TOKENS,
"cpu_threads": cpu_threads, "cpu_threads": cpu_threads,
**process_limits, **process_limits,
@@ -278,7 +335,7 @@ class FasterQwenWorkerClient:
self.settings = dict(settings) self.settings = dict(settings)
self.logger = logger self.logger = logger
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds)) self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
self.startup_timeout_seconds = max(10.0, float(startup_timeout_seconds)) self.startup_timeout_seconds = max(0.1, float(startup_timeout_seconds))
self._context = context or multiprocessing.get_context("spawn") self._context = context or multiprocessing.get_context("spawn")
self._process_target = process_target or faster_qwen_worker_main self._process_target = process_target or faster_qwen_worker_main
self._lock = threading.RLock() self._lock = threading.RLock()
@@ -330,21 +387,55 @@ class FasterQwenWorkerClient:
self._worker_pid = int(getattr(process, "pid", 0) or 0) self._worker_pid = int(getattr(process, "pid", 0) or 0)
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid) self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
if not parent_connection.poll(self.startup_timeout_seconds): started = time.monotonic()
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS deadline = started + self.startup_timeout_seconds
self._terminate_worker_locked("startup_timeout", graceful=False) last_stage = "process_spawned"
raise FasterQwenWorkerError( message: Any = None
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s" while True:
) remaining = deadline - time.monotonic()
try: if remaining <= 0:
message = parent_connection.recv() self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
except (EOFError, OSError) as exc: self._terminate_worker_locked("startup_timeout", graceful=False)
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS raise FasterQwenWorkerError(
self._terminate_worker_locked("startup_connection_closed", graceful=False) f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s "
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc f"(last_stage={last_stage})"
)
if not parent_connection.poll(min(STARTUP_PROGRESS_POLL_SECONDS, remaining)):
if not process.is_alive():
exit_code = getattr(process, "exitcode", None)
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_process_exited", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker exited during startup "
f"(exit_code={exit_code}, last_stage={last_stage})"
)
continue
try:
message = parent_connection.recv()
except (EOFError, OSError) as exc:
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_connection_closed", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker connection closed during startup "
f"(last_stage={last_stage})"
) from exc
if isinstance(message, dict) and message.get("type") == "progress":
last_stage = str(message.get("stage") or "unknown")
elapsed_ms = int((time.monotonic() - started) * 1000)
self._log(
"info",
"[FasterQwenTTS] worker 启动进度: %s, elapsed=%sms",
last_stage,
elapsed_ms,
)
continue
break
if not isinstance(message, dict) or message.get("type") != "ready": if not isinstance(message, dict) or message.get("type") != "ready":
if isinstance(message, dict): if isinstance(message, dict):
error = str(message.get("error") or message.get("error_type") or "unknown startup error") error = str(message.get("error") or message.get("error_type") or "unknown startup error")
worker_traceback = str(message.get("traceback") or "").strip()
if worker_traceback:
self._log("error", "[FasterQwenTTS] worker 启动异常:\n%s", worker_traceback)
else: else:
error = "invalid startup response" error = "invalid startup response"
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
@@ -353,10 +444,13 @@ class FasterQwenWorkerClient:
self._next_start_after = 0.0 self._next_start_after = 0.0
self._log( self._log(
"info", "info",
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s", "[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms "
"graph=%sms voice_clone=%sms max_new_tokens=%s",
self.worker_pid, self.worker_pid,
message.get("load_ms"), message.get("load_ms"),
message.get("warmup_ms"), message.get("warmup_ms"),
message.get("graph_warmup_ms"),
message.get("voice_clone_warmup_ms"),
message.get("max_new_tokens"), message.get("max_new_tokens"),
) )
self._log( self._log(
+13 -4
View File
@@ -59,7 +59,8 @@
"streaming": false, "streaming": false,
"cpu_threads": 3, "cpu_threads": 3,
"cpu_affinity_count": 6, "cpu_affinity_count": 6,
"process_priority": "below_normal" "process_priority": "below_normal",
"startup_timeout_seconds": 900
} }
}, },
"enable_danmu_reply": true, "enable_danmu_reply": true,
@@ -259,7 +260,11 @@
"daily": { "daily": {
"enabled": true, "enabled": true,
"aliases": [ "aliases": [
"自动每日" "自动每日",
"每日",
"日常",
"做每日",
"做日常"
], ],
"allowed_roles": [ "allowed_roles": [
"super_admin", "super_admin",
@@ -270,7 +275,9 @@
"enabled": true, "enabled": true,
"aliases": [ "aliases": [
"切换队伍", "切换队伍",
"更换队伍" "更换队伍",
"换队伍",
"换队"
], ],
"allowed_roles": [ "allowed_roles": [
"super_admin", "super_admin",
@@ -281,7 +288,9 @@
"enabled": true, "enabled": true,
"aliases": [ "aliases": [
"修改队员", "修改队员",
"更换队员" "更换队员",
"换队员",
"换角色"
], ],
"allowed_roles": [ "allowed_roles": [
"super_admin", "super_admin",
+15
View File
@@ -10,32 +10,46 @@ BetterGI.exe startOneDragon <配置名称>
```text ```text
自动每日 自动每日
每日
日常
做每日
做日常
自动每日 秘境 <秘境正式名称或俗称> 自动每日 秘境 <秘境正式名称或俗称>
自动每日 地脉 <经验|摩拉> <国家> 自动每日 地脉 <经验|摩拉> <国家>
自动每日 委托 自动每日 委托
切换队伍 <队伍名称> 切换队伍 <队伍名称>
更换队伍 <队伍名称> 更换队伍 <队伍名称>
换队伍 <队伍名称>
换队 <队伍名称>
修改队员 <队员1> <队员2> <队员3> <队员4> 修改队员 <队员1> <队员2> <队员3> <队员4>
更换队员 <队员1> <队员2> <队员3> <队员4> 更换队员 <队员1> <队员2> <队员3> <队员4>
换队员 <队员1> <队员2> <队员3> <队员4>
换角色 <队员1> <队员2> <队员3> <队员4>
``` ```
示例: 示例:
```text ```text
自动每日 自动每日
每日
日常 秘境 风本
自动每日 秘境 风本 自动每日 秘境 风本
自动每日 秘境 少女套 自动每日 秘境 少女套
自动每日 地脉 经验 蒙德 自动每日 地脉 经验 蒙德
自动每日 地脉 摩拉 枫丹 自动每日 地脉 摩拉 枫丹
自动每日 委托 自动每日 委托
切换队伍 永冻队 切换队伍 永冻队
换队 永冻队
修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海 修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海
换角色 神里绫华 申鹤 枫原万叶 珊瑚宫心海
修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海 修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海
``` ```
队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。 队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。
执行类指令可以省略指令后的空格,例如 `每日秘境 风本``换队永冻队``换角色芙宁娜纳西妲钟离雷电将军`。系统按最长别名优先解析,因此“换队员”不会被误识别为“换队”。
## 每日流程 ## 每日流程
每日流程固定为: 每日流程固定为:
@@ -177,3 +191,4 @@ User/JsScript/AutoSwitchRoles/combat_avatar.json
- 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json``combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。 - 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json``combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。
- BGI 启动失败:确认版本为 `0.63.0+``bettergi.exe_path``bettergi.work_dir` 正确,并查看 BetterGI 日志。 - BGI 启动失败:确认版本为 `0.63.0+``bettergi.exe_path``bettergi.work_dir` 正确,并查看 BetterGI 日志。
- 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。 - 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。
- 自动秘境异常后扫码上号卡在“退出秘境”:确认项目维护的 `integrations/扫码上号` 已同步。脚本会 OCR 识别“退出秘境”并点击右侧确认,OCR 按钮定位失败时使用 1920×1080 坐标 `(1164, 757)` 兜底。
+77 -1
View File
@@ -1,6 +1,6 @@
(async function () { (async function () {
// ======================================== // ========================================
// 扫码上号 v3 // 扫码上号 v3.1
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成 // 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
// 状态文件: status.txt (登录中 / 已登录) // 状态文件: status.txt (登录中 / 已登录)
// ======================================== // ========================================
@@ -20,6 +20,25 @@
// 脚本启动 → 写入"登录中" // 脚本启动 → 写入"登录中"
writeStatus("登录中"); writeStatus("登录中");
function settingNumber(name, fallback) {
const value = typeof settings !== "undefined" && settings ? Number(settings[name]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function settingBoolean(name, fallback) {
if (typeof settings === "undefined" || !settings || settings[name] === undefined) return fallback;
return settings[name] !== false && String(settings[name]).toLowerCase() !== "false";
}
const useExitDomainOcr = settingBoolean("useOcr", true);
let confirmExitX = settingNumber("confirmExitX", 1164);
let confirmExitY = settingNumber("confirmExitY", 757);
if (confirmExitX === 830 && confirmExitY === 600) {
confirmExitX = 1164;
confirmExitY = 757;
log.info("检测到旧版退出秘境坐标,已自动修正为 (1164, 757)");
}
// ---------- 加载图像资源 ---------- // ---------- 加载图像资源 ----------
const tapMat = file.readImageMatSync("assets/tap.png"); const tapMat = file.readImageMatSync("assets/tap.png");
const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png"); const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png");
@@ -115,6 +134,54 @@
return null; return null;
} }
function normalizeOcrText(text) {
return String(text || "").replace(/\s+/g, "");
}
function readOcrResults(x, y, w, h) {
const cap = captureGameRegion();
try {
const results = cap.findMulti(RecognitionObject.ocr(x, y, w, h));
return Array.from(results).map((result) => ({
text: normalizeOcrText(result.text),
x: Number(result.x || 0),
y: Number(result.y || 0),
w: Number(result.width || result.Width || 0),
h: Number(result.height || result.Height || 0)
}));
} catch (e) {
log.warn("退出秘境弹窗 OCR 失败: " + e);
return [];
} finally {
cap.dispose();
}
}
async function handleExitDomainDialog() {
const results = readOcrResults(480, 220, 960, 620);
const combinedText = results.map((result) => result.text).join("");
if (!combinedText.includes("退出秘境")) return false;
log.info("检测到退出秘境确认弹窗");
const confirmResult = results
.filter((result) => result.text.includes("确认") && result.x >= 900)
.sort((left, right) => right.x - left.x)[0];
if (useExitDomainOcr && confirmResult) {
const x = Math.round(confirmResult.x + confirmResult.w / 2);
const y = Math.round(confirmResult.y + confirmResult.h / 2);
log.info(`OCR 定位确认按钮,点击 (${x}, ${y})`);
click(x, y);
} else {
const reason = useExitDomainOcr ? "OCR 未定位到确认按钮" : "已关闭 OCR 按钮定位";
log.warn(`${reason},使用固定坐标 (${confirmExitX}, ${confirmExitY})`);
click(confirmExitX, confirmExitY);
}
log.info("已点击退出秘境确认,等待返回主界面...");
await sleep(6000);
return true;
}
// ======================================== // ========================================
// 前置处理:检测登录前的提示图标 // 前置处理:检测登录前的提示图标
// 检测到则点击 (1830, 985)1秒后点击 (1100, 675),再1秒后进入初始判定; // 检测到则点击 (1830, 985)1秒后点击 (1100, 675),再1秒后进入初始判定;
@@ -155,11 +222,20 @@
log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像"); log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像");
let paimonFound = findImageMatchRobust(paimonMat); let paimonFound = findImageMatchRobust(paimonMat);
if (!paimonFound && await handleExitDomainDialog()) {
paimonFound = findImageMatchRobust(paimonMat);
if (paimonFound) log.info("退出秘境后已检测到派蒙头像");
}
for (let i = 0; !paimonFound && i < 8; i++) { for (let i = 0; !paimonFound && i < 8; i++) {
log.info(`${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`); log.info(`${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`);
keyPress("Escape"); keyPress("Escape");
await sleep(1000); await sleep(1000);
if (await handleExitDomainDialog()) {
log.info("退出秘境弹窗已处理,重新检测主界面");
}
paimonFound = findImageMatchRobust(paimonMat); paimonFound = findImageMatchRobust(paimonMat);
if (paimonFound) log.info("已检测到派蒙头像,继续退出账号流程");
} }
if (!paimonFound) { if (!paimonFound) {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 1, "manifest_version": 1,
"name": "扫码上号", "name": "扫码上号",
"version": "1.0", "version": "1.1",
"bgi_version": "0.48.0", "bgi_version": "0.48.0",
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码", "description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
"authors": [ "authors": [
+5 -5
View File
@@ -44,7 +44,7 @@
{ {
"name": "useOcr", "name": "useOcr",
"type": "checkbox", "type": "checkbox",
"label": "使用OCR识别文本(否则用固定坐标)", "label": "退出秘境确认按钮使用 OCR 定位(关闭后使用固定坐标",
"default": true "default": true
}, },
{ {
@@ -53,14 +53,14 @@
{ {
"name": "confirmExitX", "name": "confirmExitX",
"type": "input-text", "type": "input-text",
"label": "确认退出按钮 X(固定坐标模式)", "label": "退出秘境确认按钮 X(1920×1080 固定坐标",
"default": "830" "default": "1164"
}, },
{ {
"name": "confirmExitY", "name": "confirmExitY",
"type": "input-text", "type": "input-text",
"label": "确认退出按钮 Y(固定坐标模式)", "label": "退出秘境确认按钮 Y(1920×1080 固定坐标",
"default": "600" "default": "757"
}, },
{ {
"name": "qrLoginX", "name": "qrLoginX",
+89 -3
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import logging import logging
import tempfile import tempfile
import unittest import unittest
@@ -21,6 +22,8 @@ from app.danmu_queue import (
COMMAND_ALLOWED_ROLE_DEFAULTS, COMMAND_ALLOWED_ROLE_DEFAULTS,
BetterGIRunner, BetterGIRunner,
CommandHandler, CommandHandler,
Config,
build_public_command_hints,
) )
@@ -32,14 +35,17 @@ class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
data={ data={
"commands": { "commands": {
"run": {"enabled": True, "aliases": ["执行", ""]}, "run": {"enabled": True, "aliases": ["执行", ""]},
"daily": {"enabled": True, "aliases": ["自动每日"]}, "daily": {
"enabled": True,
"aliases": ["自动每日", "每日", "日常", "做每日", "做日常"],
},
"switch_party": { "switch_party": {
"enabled": True, "enabled": True,
"aliases": ["切换队伍", "更换队伍"], "aliases": ["切换队伍", "更换队伍", "换队伍", "换队"],
}, },
"edit_party": { "edit_party": {
"enabled": True, "enabled": True,
"aliases": ["修改队员", "更换队员"], "aliases": ["修改队员", "更换队员", "换队员", "换角色"],
}, },
} }
}, },
@@ -315,6 +321,86 @@ class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"), self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"),
("修改队员", "芙宁娜纳西妲钟离雷电将军"), ("修改队员", "芙宁娜纳西妲钟离雷电将军"),
) )
self.assertEqual(
self.handler._split_command_text("日常秘境 风本"),
("日常", "秘境 风本"),
)
self.assertEqual(
self.handler._split_command_text("换队永冻队"),
("换队", "永冻队"),
)
self.assertEqual(
self.handler._split_command_text("换角色芙宁娜纳西妲钟离雷电将军"),
("换角色", "芙宁娜纳西妲钟离雷电将军"),
)
def test_longest_new_alias_wins_for_contiguous_arguments(self):
self.assertEqual(
self.handler._split_command_text("换队员芙宁娜纳西妲钟离雷电将军"),
("换队员", "芙宁娜纳西妲钟离雷电将军"),
)
async def test_confirmed_login_announces_daily_and_party_usage(self):
self.queue_mgr.state["login_status"] = "confirming"
await self.handler._cmd_confirm_login(123, "测试用户", "")
self.assertEqual(self.broadcaster.broadcast.await_count, 2)
first, second = self.broadcaster.broadcast.await_args_list
self.assertIn("发送“每日”完成基础每日", first.args[0])
self.assertTrue(first.kwargs["tts"])
self.assertIn("每日 秘境 风本", second.args[0])
self.assertIn("换队 永冻队", second.args[0])
self.assertIn("换角色 四名角色", second.args[0])
self.assertIn("执行 组名", second.args[0])
self.assertFalse(second.kwargs["tts"])
self.assertTrue(second.kwargs["danmu"])
def test_public_command_hints_are_sanitized_and_use_shortest_alias(self):
config_data = {
"commands": self.config.data["commands"],
"bilibili": {"cookie": "secret"},
}
hints = build_public_command_hints(config_data)
self.assertEqual(set(hints), {"daily", "switch_party", "edit_party"})
self.assertEqual(hints["daily"]["preferred_alias"], "每日")
self.assertEqual(hints["switch_party"]["preferred_alias"], "换队")
self.assertEqual(hints["edit_party"]["preferred_alias"], "换角色")
self.assertNotIn("bilibili", hints)
def test_new_command_alias_defaults_are_complete(self):
config_path = self.root / "config.json"
config_path.write_text(json.dumps({}), encoding="utf-8")
config = Config(str(config_path))
self.assertEqual(
config.data["commands"]["daily"]["aliases"],
["自动每日", "每日", "日常", "做每日", "做日常"],
)
self.assertEqual(
config.data["commands"]["switch_party"]["aliases"],
["切换队伍", "更换队伍", "换队伍", "换队"],
)
self.assertEqual(
config.data["commands"]["edit_party"]["aliases"],
["修改队员", "更换队员", "换队员", "换角色"],
)
def test_disabled_command_hint_has_no_public_alias(self):
config_data = {
"commands": {
"daily": {"enabled": False, "aliases": ["每日"]},
}
}
hints = build_public_command_hints(config_data)
self.assertFalse(hints["daily"]["enabled"])
self.assertEqual(hints["daily"]["aliases"], [])
self.assertEqual(hints["daily"]["preferred_alias"], "")
def test_new_command_permissions_match_run(self): def test_new_command_permissions_match_run(self):
for key in ("daily", "switch_party", "edit_party"): for key in ("daily", "switch_party", "edit_party"):
+86
View File
@@ -9,6 +9,7 @@ from app.faster_qwen_worker import (
MAX_NEW_TOKENS, MAX_NEW_TOKENS,
FasterQwenWorkerClient, FasterQwenWorkerClient,
FasterQwenWorkerTimeout, FasterQwenWorkerTimeout,
faster_qwen_worker_main,
_generation_kwargs, _generation_kwargs,
_configure_worker_environment, _configure_worker_environment,
) )
@@ -91,6 +92,14 @@ def _ready(pid):
} }
def _progress(stage):
return {
"type": "progress",
"stage": stage,
"pid": 4000,
}
def _probe_worker_main(connection, _settings): def _probe_worker_main(connection, _settings):
connection.send({ connection.send({
"type": "ready", "type": "ready",
@@ -153,6 +162,8 @@ class FasterQwenWorkerTests(unittest.TestCase):
def test_client_returns_worker_audio(self): def test_client_returns_worker_audio(self):
connection = _FakeConnection([ connection = _FakeConnection([
_progress("model_loading"),
_progress("warmup_started"),
_ready(4100), _ready(4100),
{ {
"type": "result", "type": "result",
@@ -181,6 +192,81 @@ class FasterQwenWorkerTests(unittest.TestCase):
self.assertEqual(len(context.processes), 1) self.assertEqual(len(context.processes), 1)
client.close() client.close()
def test_client_default_startup_timeout_allows_slow_model_load(self):
client = FasterQwenWorkerClient({})
self.assertEqual(client.startup_timeout_seconds, 900.0)
client.close()
def test_worker_applies_process_limits_after_model_warmup(self):
order = []
class FakeModel:
def warmup(self):
order.append("warmup")
class WorkerConnection:
def __init__(self):
self.sent = []
self.closed = False
def send(self, message):
self.sent.append(message)
def recv(self):
return {"command": "stop"}
def close(self):
self.closed = True
connection = WorkerConnection()
with (
patch("app.faster_qwen_worker._configure_worker_environment", return_value=3),
patch(
"app.faster_qwen_worker._load_runtime",
side_effect=lambda _settings, progress=None: (
progress("from_pretrained_started") if progress else None,
order.append("load"),
{"model": FakeModel()},
)[-1],
),
patch(
"app.faster_qwen_worker._apply_worker_process_limits",
side_effect=lambda _settings: order.append("limits") or {
"cpu_affinity_count": 6,
"process_priority": "below_normal",
},
),
patch(
"app.faster_qwen_worker._synthesize_wav",
side_effect=lambda _runtime, _text: order.append("voice_clone") or b"wav",
),
):
faster_qwen_worker_main(connection, {})
self.assertEqual(order, ["load", "warmup", "voice_clone", "limits"])
progress_stages = [
item.get("stage")
for item in connection.sent
if item.get("type") == "progress"
]
self.assertEqual(
progress_stages,
[
"process_started",
"environment_ready",
"model_loading",
"from_pretrained_started",
"model_loaded",
"warmup_started",
"graph_warmup_complete",
"voice_clone_warmup_started",
"voice_clone_warmup_complete",
"warmup_complete",
],
)
self.assertEqual(connection.sent[-1]["type"], "ready")
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self): def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
first_connection = _FakeConnection([_ready(4200)]) first_connection = _FakeConnection([_ready(4200)])
replacement_connection = _FakeConnection([_ready(4300)]) replacement_connection = _FakeConnection([_ready(4300)])
+33
View File
@@ -0,0 +1,33 @@
import json
import unittest
from pathlib import Path
class LoginScriptDomainExitTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.script_dir = Path(__file__).resolve().parents[1] / "integrations" / "扫码上号"
cls.source = (cls.script_dir / "main.js").read_text(encoding="utf-8")
cls.settings = json.loads((cls.script_dir / "settings.json").read_text(encoding="utf-8"))
cls.manifest = json.loads((cls.script_dir / "manifest.json").read_text(encoding="utf-8"))
def test_domain_exit_requires_specific_ocr_text_before_click(self):
self.assertIn('combinedText.includes("退出秘境")', self.source)
self.assertIn('result.text.includes("确认") && result.x >= 900', self.source)
def test_legacy_and_default_confirm_coordinates_are_corrected(self):
defaults = {
item.get("name"): item.get("default")
for item in self.settings
if isinstance(item, dict) and item.get("name")
}
self.assertEqual(defaults["confirmExitX"], "1164")
self.assertEqual(defaults["confirmExitY"], "757")
self.assertIn("confirmExitX === 830 && confirmExitY === 600", self.source)
def test_private_login_script_version_is_updated(self):
self.assertEqual(self.manifest["version"], "1.1")
if __name__ == "__main__":
unittest.main()
+165 -1
View File
@@ -1,10 +1,12 @@
import asyncio import asyncio
import ctypes
import logging import logging
import unittest import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from app.danmu_queue import SystemScheduler from app.danmu_queue import CommandHandler, QueueSystem, SystemScheduler
class _FakeConfig: class _FakeConfig:
@@ -155,5 +157,167 @@ class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
) )
class LivehimePushVerificationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.logger = logging.getLogger("test-livehime-push-verification")
self.scheduler = SystemScheduler(
_FakeConfig({
"bilibili_push_window_keyword": "直播姬",
"bilibili_push_click_x_ratio": 0.787,
"bilibili_push_click_y_ratio": 0.927,
"bilibili_stop_push_click_x_ratio": 0.787,
"bilibili_stop_push_click_y_ratio": 0.927,
}),
self.logger,
)
async def test_push_skips_click_when_bilibili_is_already_live(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=1)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.push_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_push_clicks_then_waits_for_confirmed_live_status(self):
self.scheduler._get_bilibili_live_status = AsyncMock(side_effect=[0, 0, 1])
self.scheduler._click_bilibili_live = AsyncMock(return_value=True)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler.push_bilibili_live()
self.assertTrue(result)
self.scheduler._click_bilibili_live.assert_awaited_once()
async def test_stop_skips_click_when_bilibili_is_already_offline(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.stop_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_live_status_timeout_returns_specific_error(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler._wait_for_bilibili_live_status(
1,
timeout_seconds=0.01,
)
self.assertFalse(result)
self.assertIn("未确认开播", self.scheduler.last_live_action_error)
@patch("app.danmu_queue.os.name", "nt")
async def test_click_restores_and_focuses_before_reading_physical_window_rect(self):
hwnd = 12345
user32 = MagicMock()
call_order = []
user32.IsIconic.return_value = True
user32.GetForegroundWindow.return_value = hwnd
user32.GetSystemMetrics.side_effect = lambda index: {
76: 0,
77: 0,
78: 2560,
79: 1440,
}[index]
user32.SetCursorPos.return_value = True
user32.GetCursorPos.return_value = False
user32.SetThreadDpiAwarenessContext.return_value = None
def show_window(_hwnd, _command):
call_order.append("restore")
return True
def get_window_rect(_hwnd, pointer):
call_order.append("rect")
rect = ctypes.cast(pointer, ctypes.POINTER(ctypes.wintypes.RECT)).contents
rect.left = -11
rect.top = -11
rect.right = 2571
rect.bottom = 1379
return True
user32.ShowWindow.side_effect = show_window
user32.GetWindowRect.side_effect = get_window_rect
self.scheduler._find_window_by_keyword = MagicMock(return_value=hwnd)
self.scheduler._force_foreground_window = MagicMock(return_value=True)
with (
patch("app.danmu_queue.ctypes.windll.user32", user32),
patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock),
):
result = await self.scheduler._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮",
)
self.assertTrue(result)
self.assertLess(call_order.index("restore"), call_order.index("rect"))
user32.SetCursorPos.assert_called_once_with(2021, 1277)
self.assertEqual(user32.mouse_event.call_count, 2)
class ManualLiveOverrideTests(unittest.TestCase):
def setUp(self):
self.system = QueueSystem.__new__(QueueSystem)
self.system.logger = logging.getLogger("test-manual-live-override")
self.system._manual_live_override_until_monotonic = 0.0
self.system._manual_live_override_until_wall = 0.0
self.system._bilibili_live_status = None
self.system._bilibili_live_status_updated_monotonic = 0.0
self.system._bilibili_live_status_updated_wall = 0.0
def test_manual_live_override_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with patch("app.danmu_queue.time.monotonic", return_value=100.0):
self.system.enable_manual_live_override(300)
self.assertTrue(handler._is_live_time())
snapshot = self.system.manual_live_override_snapshot()
self.assertTrue(snapshot["active"])
self.assertGreater(snapshot["until"], 0)
def test_clear_manual_live_override_restores_schedule_gate(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.enable_manual_live_override(300)
self.system.clear_manual_live_override()
self.assertFalse(handler._is_live_time())
def test_confirmed_bilibili_live_status_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.set_bilibili_live_status(1)
self.assertTrue(handler._is_live_time())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+44
View File
@@ -2,6 +2,7 @@ import asyncio
import logging import logging
import time import time
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import numpy as np import numpy as np
@@ -144,6 +145,49 @@ class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
await asyncio.gather(synth_worker, play_worker, return_exceptions=True) await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
class BackgroundWarmupTests(unittest.IsolatedAsyncioTestCase):
async def test_start_returns_before_tts_warmup_finishes(self):
warmup_started = asyncio.Event()
release_warmup = asyncio.Event()
class FakeTTS:
enabled = True
async def warmup(self):
warmup_started.set()
await release_warmup.wait()
async def idle_worker():
await asyncio.Event().wait()
broadcaster = Broadcaster.__new__(Broadcaster)
broadcaster._tts_started = False
broadcaster._tts_warmup_done = asyncio.Event()
broadcaster._tts_warmup_task = None
broadcaster._tts_worker_tasks = set()
broadcaster._tts_queue_cfg = {"warmup_on_start": True}
broadcaster._tts_pending = SimpleNamespace(maxsize=8)
broadcaster._tts_playback_queue = SimpleNamespace(maxsize=2)
broadcaster._tts_synthesis_loop = idle_worker
broadcaster._tts_playback_loop = idle_worker
broadcaster.tts = FakeTTS()
broadcaster.logger = logging.getLogger("tts-background-warmup-test")
await asyncio.wait_for(broadcaster.start(), timeout=0.2)
await asyncio.wait_for(warmup_started.wait(), timeout=0.2)
self.assertFalse(broadcaster._tts_warmup_done.is_set())
self.assertIsNotNone(broadcaster._tts_warmup_task)
release_warmup.set()
await asyncio.wait_for(broadcaster._tts_warmup_done.wait(), timeout=0.2)
tasks = list(broadcaster._tts_worker_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
class TTSExpiryPolicyTests(unittest.TestCase): class TTSExpiryPolicyTests(unittest.TestCase):
def test_default_expiry_windows_match_configured_policy(self): def test_default_expiry_windows_match_configured_policy(self):
broadcaster = Broadcaster.__new__(Broadcaster) broadcaster = Broadcaster.__new__(Broadcaster)
File diff suppressed because one or more lines are too long
+124 -11
View File
@@ -47,9 +47,10 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: center; justify-content: flex-start;
background: transparent; background: transparent;
overflow: hidden;
} }
body.preview #viewport { body.preview #viewport {
@@ -1315,6 +1316,34 @@
border: 1px solid rgba(255, 242, 201, 0.13); border: 1px solid rgba(255, 242, 201, 0.13);
} }
.command-quick-reference {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 12px;
margin: 0 12px 2px;
padding: 3px 8px;
border-top: 1px solid rgba(255, 242, 201, 0.15);
border-bottom: 1px solid rgba(255, 242, 201, 0.15);
color: rgba(244, 255, 247, 0.86);
font-size: 12px;
font-weight: 720;
line-height: 1.25;
}
.command-quick-reference span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-quick-reference strong {
color: #fff0b5;
font-weight: 900;
}
.tutorial-stage { .tutorial-stage {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -1322,19 +1351,20 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 14px 3px; min-height: 32px;
padding: 0 10px 1px;
overflow: hidden; overflow: hidden;
} }
.tutorial-text { .tutorial-text {
width: 100%; width: 100%;
color: var(--text); color: var(--text);
font-size: 26px; font-size: 20px;
font-weight: 900; font-weight: 900;
line-height: 1.35; line-height: 1.25;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.7px; letter-spacing: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18); text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18);
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -1342,12 +1372,12 @@
} }
.tutorial-text.long { .tutorial-text.long {
font-size: 23px; font-size: 18px;
letter-spacing: 0.35px; letter-spacing: 0;
} }
.tutorial-text.extra-long { .tutorial-text.extra-long {
font-size: 21px; font-size: 16px;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -1852,6 +1882,7 @@
<!-- 右侧中部:教学轮播 --> <!-- 右侧中部:教学轮播 -->
<section class="panel tutorial-panel"> <section class="panel tutorial-panel">
<div class="panel-title tutorial-title">直播间使用说明</div> <div class="panel-title tutorial-title">直播间使用说明</div>
<div id="command-quick-reference" class="command-quick-reference"></div>
<div class="tutorial-stage"> <div class="tutorial-stage">
<div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div> <div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div>
</div> </div>
@@ -1898,7 +1929,7 @@
return m + ":" + String(sec % 60).padStart(2, "0"); return m + ":" + String(sec % 60).padStart(2, "0");
} }
const tutorialSlides = [ const baseTutorialSlides = [
"发送“签到”领取积分,发送“排队”加入队伍", "发送“签到”领取积分,发送“排队”加入队伍",
"成为队首后,请在90秒内发送“上号”", "成为队首后,请在90秒内发送“上号”",
"发送“上号”后,请在4分钟内完成扫码登录", "发送“上号”后,请在4分钟内完成扫码登录",
@@ -1907,8 +1938,85 @@
"发送“积分”查询积分,发送“队列”查看排队情况", "发送“积分”查询积分,发送“队列”查看排队情况",
"发送“退出”离开队伍,发送“点歌 歌名”点歌" "发送“退出”离开队伍,发送“点歌 歌名”点歌"
]; ];
const fallbackCommandHints = {
daily: { enabled: true, aliases: ["自动每日", "每日", "日常"], preferred_alias: "每日" },
switch_party: { enabled: true, aliases: ["切换队伍", "换队"], preferred_alias: "换队" },
edit_party: { enabled: true, aliases: ["修改队员", "换角色"], preferred_alias: "换角色" }
};
let commandHints = fallbackCommandHints;
let tutorialSlides = baseTutorialSlides.slice();
let tutorialIndex = 0; let tutorialIndex = 0;
function normalizeCommandHint(value, fallback) {
const source = value && typeof value === "object" ? value : fallback;
const aliases = Array.isArray(source.aliases)
? source.aliases.map((item) => String(item || "").trim()).filter(Boolean)
: fallback.aliases.slice();
const preferred = String(source.preferred_alias || "").trim()
|| aliases.reduce((best, item) => !best || item.length < best.length ? item : best, "")
|| fallback.preferred_alias;
return {
enabled: source.enabled !== false,
aliases,
preferred_alias: preferred
};
}
function renderCommandQuickReference() {
const el = document.getElementById("command-quick-reference");
if (!el) return;
const daily = commandHints.daily;
if (!daily || !daily.enabled) {
el.hidden = true;
el.innerHTML = "";
return;
}
const cmd = esc(daily.preferred_alias || "每日");
el.hidden = false;
el.innerHTML = [
`<span><strong>${cmd}</strong>:基础流程</span>`,
`<span><strong>${cmd} 秘境</strong> &lt;名称&gt;</span>`,
`<span><strong>${cmd} 地脉</strong> &lt;类型&gt; &lt;国家&gt;</span>`,
`<span><strong>${cmd} 委托</strong>:每日委托</span>`
].join("");
}
function rebuildTutorialSlides() {
const slides = baseTutorialSlides.slice();
const daily = commandHints.daily;
const switchParty = commandHints.switch_party;
const editParty = commandHints.edit_party;
if (daily && daily.enabled) {
const cmd = daily.preferred_alias || "每日";
slides.push(`发送“${cmd}”完成邮件、树脂、尘歌壶和每日奖励`);
slides.push(`发送“${cmd} 秘境 风本”刷到树脂耗尽`);
slides.push(`发送“${cmd} 地脉 经验 蒙德”刷经验地脉`);
slides.push(`发送“${cmd} 委托”执行每日委托配置`);
}
const partyParts = [];
if (switchParty && switchParty.enabled) {
partyParts.push(`“${switchParty.preferred_alias || "换队"} 永冻队”切换队伍`);
}
if (editParty && editParty.enabled) {
partyParts.push(`“${editParty.preferred_alias || "换角色"} 四名角色”修改队员`);
}
if (partyParts.length) slides.push(`发送${partyParts.join(",发送")}`);
tutorialSlides = slides;
tutorialIndex %= tutorialSlides.length;
renderCommandQuickReference();
showTutorialSlide(tutorialIndex, false);
}
function applyCommandHints(value) {
const source = value && typeof value === "object" ? value : {};
commandHints = {
daily: normalizeCommandHint(source.daily, fallbackCommandHints.daily),
switch_party: normalizeCommandHint(source.switch_party, fallbackCommandHints.switch_party),
edit_party: normalizeCommandHint(source.edit_party, fallbackCommandHints.edit_party)
};
rebuildTutorialSlides();
}
function renderTutorialDots() { function renderTutorialDots() {
const dots = document.getElementById("tutorial-dots"); const dots = document.getElementById("tutorial-dots");
if (!dots) return; if (!dots) return;
@@ -2344,8 +2452,10 @@
themeClasses.forEach((c) => document.body.classList.remove(c)); themeClasses.forEach((c) => document.body.classList.remove(c));
const theme = cfg.theme || "classic"; const theme = cfg.theme || "classic";
if (theme && theme !== "classic") document.body.classList.add("theme-" + theme); if (theme && theme !== "classic") document.body.classList.add("theme-" + theme);
applyCommandHints(cfg.command_hints);
} catch (e) { } catch (e) {
console.error("frontend-config", e); console.error("frontend-config", e);
applyCommandHints(fallbackCommandHints);
} }
} }
@@ -2647,9 +2757,12 @@
function scaleCanvas() { function scaleCanvas() {
const canvas = document.getElementById("canvas"); const canvas = document.getElementById("canvas");
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080); const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
canvas.style.transform = "scale(" + scale + ")"; const offsetX = Math.max(0, (window.innerWidth - 1920 * scale) / 2);
const offsetY = Math.max(0, (window.innerHeight - 1080 * scale) / 2);
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
} }
applyCommandHints(fallbackCommandHints);
startTutorialCarousel(); startTutorialCarousel();
fetchFrontendConfig(); fetchFrontendConfig();
fetchState(); fetchState();