Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ff69e945 |
@@ -2,7 +2,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
.venv-tts/
|
|
||||||
runtime/
|
runtime/
|
||||||
config/.venv_python_path.txt
|
config/.venv_python_path.txt
|
||||||
|
|
||||||
@@ -22,10 +21,6 @@ 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
|
||||||
@@ -35,4 +30,3 @@ 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/
|
|
||||||
|
|||||||
@@ -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` | 积分 | 查询积分 |
|
||||||
|
|||||||
+36
-392
@@ -61,10 +61,6 @@ 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]:
|
||||||
@@ -251,72 +247,6 @@ 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]:
|
||||||
@@ -581,7 +511,6 @@ 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)
|
||||||
@@ -694,9 +623,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": list(NEW_COMMAND_ALIAS_DEFAULTS["daily"])},
|
"daily": {"enabled": True, "aliases": ["自动每日"]},
|
||||||
"switch_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["switch_party"])},
|
"switch_party": {"enabled": True, "aliases": ["切换队伍", "更换队伍"]},
|
||||||
"edit_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["edit_party"])},
|
"edit_party": {"enabled": True, "aliases": ["修改队员", "更换队员"]},
|
||||||
"leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]},
|
"leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]},
|
||||||
"reset": {"enabled": True, "aliases": ["重置"]},
|
"reset": {"enabled": True, "aliases": ["重置"]},
|
||||||
"points": {"enabled": True, "aliases": ["积分"]},
|
"points": {"enabled": True, "aliases": ["积分"]},
|
||||||
@@ -2521,10 +2450,6 @@ 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
|
||||||
@@ -2548,7 +2473,6 @@ 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")
|
||||||
@@ -2796,7 +2720,6 @@ 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 失效, 关闭弹幕发送
|
||||||
@@ -2839,14 +2762,9 @@ 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:
|
||||||
@@ -2859,16 +2777,10 @@ 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)
|
||||||
|
|
||||||
if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
|
try:
|
||||||
warmup_task = asyncio.create_task(
|
if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
|
||||||
self._warmup_tts_in_background(self.tts),
|
await self.tts.warmup()
|
||||||
name="TTS后台预热",
|
finally:
|
||||||
)
|
|
||||||
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}, "
|
||||||
@@ -5373,11 +5285,6 @@ 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:
|
||||||
@@ -5883,22 +5790,7 @@ 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}) 确认账号正确,升为二级")
|
||||||
daily_alias = preferred_command_alias(self.config.data, "daily", "自动每日")
|
await self.broadcast(f"「{uname}」账号已确认,发送\"执行 组名\"开始(如: 执行 泡泡桔)")
|
||||||
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)
|
||||||
@@ -6617,8 +6509,12 @@ class CommandHandler:
|
|||||||
|
|
||||||
def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str:
|
def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str:
|
||||||
"""获取指令的别名展示字符串,用于帮助信息。"""
|
"""获取指令的别名展示字符串,用于帮助信息。"""
|
||||||
aliases = configured_command_aliases(self.config.data, key, (fallback,))
|
cmds = self.config.data.get("commands", {})
|
||||||
return "/".join(aliases) if aliases else fallback
|
cfg = cmds.get(key, {})
|
||||||
|
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", "排队")
|
||||||
@@ -6971,23 +6867,6 @@ 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 获取弹幕服务器和专用 token;token 不得回退为 SESSDATA。"""
|
"""使用登录 Cookie 获取弹幕服务器和专用 token;token 不得回退为 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)}"
|
||||||
@@ -7362,9 +7241,6 @@ 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:
|
||||||
@@ -7576,89 +7452,6 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -7667,137 +7460,57 @@ 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._last_live_action_error = f"当前系统不支持自动{action_name}"
|
self.logger.warning(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._last_live_action_error = f"未找到直播姬窗口,关键字: {keyword}"
|
self.logger.warning(f"[系统定时] 未找到直播姬窗口,关键字: {keyword}")
|
||||||
self.logger.warning(f"[系统定时] {self._last_live_action_error}")
|
|
||||||
return False
|
return False
|
||||||
user32 = ctypes.windll.user32
|
user32 = ctypes.windll.user32
|
||||||
previous_dpi_context = None
|
rect = ctypes.wintypes.RECT()
|
||||||
set_thread_dpi_context = getattr(user32, "SetThreadDpiAwarenessContext", None)
|
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
||||||
|
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:
|
||||||
if set_thread_dpi_context:
|
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||||||
set_thread_dpi_context.restype = ctypes.c_void_p
|
user32.SetForegroundWindow(hwnd)
|
||||||
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)
|
||||||
if not self._force_foreground_window(hwnd):
|
user32.SetCursorPos(x, y)
|
||||||
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(
|
self.logger.info(f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}")
|
||||||
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._last_live_action_error = f"点击直播姬{action_name}失败: {e}"
|
self.logger.warning(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:
|
||||||
self._last_live_action_error = ""
|
return await self._click_bilibili_live(
|
||||||
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:
|
||||||
self._last_live_action_error = ""
|
return await self._click_bilibili_live(
|
||||||
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"):
|
||||||
@@ -7964,13 +7677,6 @@ 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):
|
||||||
@@ -8038,11 +7744,6 @@ 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(
|
||||||
@@ -8091,54 +7792,6 @@ 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
|
||||||
@@ -9570,8 +9223,6 @@ 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
|
||||||
|
|
||||||
@@ -10159,7 +9810,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", ""),
|
||||||
@@ -10167,7 +9818,6 @@ 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):
|
||||||
@@ -10634,19 +10284,13 @@ 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:
|
||||||
error = self.scheduler.last_live_action_error or "直播姬开播测试失败"
|
return web.json_response({"success": False, "error": "未找到直播姬窗口或点击失败"}, status=400)
|
||||||
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:
|
||||||
error = self.scheduler.last_live_action_error or "直播姬关播测试失败"
|
return web.json_response({"success": False, "error": "未找到直播姬窗口或关闭推流点击失败"}, status=400)
|
||||||
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()
|
||||||
|
|||||||
+18
-112
@@ -3,7 +3,6 @@ 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
|
||||||
@@ -14,14 +13,13 @@ 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 = 900.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.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):
|
||||||
@@ -124,26 +122,15 @@ def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_runtime(
|
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
|
||||||
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
|
||||||
@@ -151,12 +138,10 @@ def _load_runtime(
|
|||||||
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 方法,预计算只会失败。改为每次合成时在
|
||||||
@@ -208,63 +193,21 @@ 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)
|
||||||
report("environment_ready", cpu_threads=cpu_threads)
|
process_limits = _apply_worker_process_limits(settings)
|
||||||
load_started = time.monotonic()
|
load_started = time.monotonic()
|
||||||
report("model_loading", model_name=str(settings.get("model_name_or_path") or ""))
|
runtime = _load_runtime(settings)
|
||||||
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,
|
||||||
@@ -335,7 +278,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(0.1, float(startup_timeout_seconds))
|
self.startup_timeout_seconds = max(10.0, 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()
|
||||||
@@ -387,55 +330,21 @@ 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)
|
||||||
|
|
||||||
started = time.monotonic()
|
if not parent_connection.poll(self.startup_timeout_seconds):
|
||||||
deadline = started + self.startup_timeout_seconds
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
last_stage = "process_spawned"
|
self._terminate_worker_locked("startup_timeout", graceful=False)
|
||||||
message: Any = None
|
raise FasterQwenWorkerError(
|
||||||
while True:
|
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s"
|
||||||
remaining = deadline - time.monotonic()
|
)
|
||||||
if remaining <= 0:
|
try:
|
||||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
message = parent_connection.recv()
|
||||||
self._terminate_worker_locked("startup_timeout", graceful=False)
|
except (EOFError, OSError) as exc:
|
||||||
raise FasterQwenWorkerError(
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s "
|
self._terminate_worker_locked("startup_connection_closed", graceful=False)
|
||||||
f"(last_stage={last_stage})"
|
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc
|
||||||
)
|
|
||||||
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
|
||||||
@@ -444,13 +353,10 @@ 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 "
|
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s",
|
||||||
"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(
|
||||||
|
|||||||
+4
-13
@@ -59,8 +59,7 @@
|
|||||||
"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,
|
||||||
@@ -260,11 +259,7 @@
|
|||||||
"daily": {
|
"daily": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"自动每日",
|
"自动每日"
|
||||||
"每日",
|
|
||||||
"日常",
|
|
||||||
"做每日",
|
|
||||||
"做日常"
|
|
||||||
],
|
],
|
||||||
"allowed_roles": [
|
"allowed_roles": [
|
||||||
"super_admin",
|
"super_admin",
|
||||||
@@ -275,9 +270,7 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"切换队伍",
|
"切换队伍",
|
||||||
"更换队伍",
|
"更换队伍"
|
||||||
"换队伍",
|
|
||||||
"换队"
|
|
||||||
],
|
],
|
||||||
"allowed_roles": [
|
"allowed_roles": [
|
||||||
"super_admin",
|
"super_admin",
|
||||||
@@ -288,9 +281,7 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"修改队员",
|
"修改队员",
|
||||||
"更换队员",
|
"更换队员"
|
||||||
"换队员",
|
|
||||||
"换角色"
|
|
||||||
],
|
],
|
||||||
"allowed_roles": [
|
"allowed_roles": [
|
||||||
"super_admin",
|
"super_admin",
|
||||||
|
|||||||
@@ -10,46 +10,32 @@ 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` 中维护的角色别名。
|
||||||
|
|
||||||
执行类指令可以省略指令后的空格,例如 `每日秘境 风本`、`换队永冻队`、`换角色芙宁娜纳西妲钟离雷电将军`。系统按最长别名优先解析,因此“换队员”不会被误识别为“换队”。
|
|
||||||
|
|
||||||
## 每日流程
|
## 每日流程
|
||||||
|
|
||||||
每日流程固定为:
|
每日流程固定为:
|
||||||
@@ -191,4 +177,3 @@ 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)` 兜底。
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
(async function () {
|
(async function () {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 扫码上号 v3.1
|
// 扫码上号 v3
|
||||||
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
|
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
|
||||||
// 状态文件: status.txt (登录中 / 已登录)
|
// 状态文件: status.txt (登录中 / 已登录)
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -20,25 +20,6 @@
|
|||||||
// 脚本启动 → 写入"登录中"
|
// 脚本启动 → 写入"登录中"
|
||||||
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");
|
||||||
@@ -134,54 +115,6 @@
|
|||||||
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秒后进入初始判定;
|
||||||
@@ -222,20 +155,11 @@
|
|||||||
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
"name": "扫码上号",
|
"name": "扫码上号",
|
||||||
"version": "1.1",
|
"version": "1.0",
|
||||||
"bgi_version": "0.48.0",
|
"bgi_version": "0.48.0",
|
||||||
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
|
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
|
||||||
"authors": [
|
"authors": [
|
||||||
|
|||||||
@@ -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(1920×1080 固定坐标)",
|
"label": "确认退出按钮 X(固定坐标模式)",
|
||||||
"default": "1164"
|
"default": "830"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "confirmExitY",
|
"name": "confirmExitY",
|
||||||
"type": "input-text",
|
"type": "input-text",
|
||||||
"label": "退出秘境确认按钮 Y(1920×1080 固定坐标)",
|
"label": "确认退出按钮 Y(固定坐标模式)",
|
||||||
"default": "757"
|
"default": "600"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "qrLoginX",
|
"name": "qrLoginX",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -22,8 +21,6 @@ from app.danmu_queue import (
|
|||||||
COMMAND_ALLOWED_ROLE_DEFAULTS,
|
COMMAND_ALLOWED_ROLE_DEFAULTS,
|
||||||
BetterGIRunner,
|
BetterGIRunner,
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
Config,
|
|
||||||
build_public_command_hints,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,17 +32,14 @@ class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
data={
|
data={
|
||||||
"commands": {
|
"commands": {
|
||||||
"run": {"enabled": True, "aliases": ["执行", "跑"]},
|
"run": {"enabled": True, "aliases": ["执行", "跑"]},
|
||||||
"daily": {
|
"daily": {"enabled": True, "aliases": ["自动每日"]},
|
||||||
"enabled": True,
|
|
||||||
"aliases": ["自动每日", "每日", "日常", "做每日", "做日常"],
|
|
||||||
},
|
|
||||||
"switch_party": {
|
"switch_party": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"aliases": ["切换队伍", "更换队伍", "换队伍", "换队"],
|
"aliases": ["切换队伍", "更换队伍"],
|
||||||
},
|
},
|
||||||
"edit_party": {
|
"edit_party": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"aliases": ["修改队员", "更换队员", "换队员", "换角色"],
|
"aliases": ["修改队员", "更换队员"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -321,86 +315,6 @@ 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"):
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -92,14 +91,6 @@ 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",
|
||||||
@@ -162,8 +153,6 @@ 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",
|
||||||
@@ -192,81 +181,6 @@ 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)])
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
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 CommandHandler, QueueSystem, SystemScheduler
|
from app.danmu_queue import SystemScheduler
|
||||||
|
|
||||||
|
|
||||||
class _FakeConfig:
|
class _FakeConfig:
|
||||||
@@ -157,167 +155,5 @@ 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()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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
|
||||||
@@ -145,49 +144,6 @@ 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)
|
||||||
|
|||||||
+11
-124
@@ -47,10 +47,9 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.preview #viewport {
|
body.preview #viewport {
|
||||||
@@ -1316,34 +1315,6 @@
|
|||||||
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;
|
||||||
@@ -1351,20 +1322,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 32px;
|
padding: 0 14px 3px;
|
||||||
padding: 0 10px 1px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text {
|
.tutorial-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 26px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.25;
|
line-height: 1.35;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0.7px;
|
||||||
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);
|
||||||
@@ -1372,12 +1342,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text.long {
|
.tutorial-text.long {
|
||||||
font-size: 18px;
|
font-size: 23px;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0.35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text.extra-long {
|
.tutorial-text.extra-long {
|
||||||
font-size: 16px;
|
font-size: 21px;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1882,7 +1852,6 @@
|
|||||||
<!-- 右侧中部:教学轮播 -->
|
<!-- 右侧中部:教学轮播 -->
|
||||||
<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>
|
||||||
@@ -1929,7 +1898,7 @@
|
|||||||
return m + ":" + String(sec % 60).padStart(2, "0");
|
return m + ":" + String(sec % 60).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseTutorialSlides = [
|
const tutorialSlides = [
|
||||||
"发送“签到”领取积分,发送“排队”加入队伍",
|
"发送“签到”领取积分,发送“排队”加入队伍",
|
||||||
"成为队首后,请在90秒内发送“上号”",
|
"成为队首后,请在90秒内发送“上号”",
|
||||||
"发送“上号”后,请在4分钟内完成扫码登录",
|
"发送“上号”后,请在4分钟内完成扫码登录",
|
||||||
@@ -1938,85 +1907,8 @@
|
|||||||
"发送“积分”查询积分,发送“队列”查看排队情况",
|
"发送“积分”查询积分,发送“队列”查看排队情况",
|
||||||
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
|
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
|
||||||
];
|
];
|
||||||
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> <名称></span>`,
|
|
||||||
`<span><strong>${cmd} 地脉</strong> <类型> <国家></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;
|
||||||
@@ -2452,10 +2344,8 @@
|
|||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2757,12 +2647,9 @@
|
|||||||
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);
|
||||||
const offsetX = Math.max(0, (window.innerWidth - 1920 * scale) / 2);
|
canvas.style.transform = "scale(" + scale + ")";
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user