Files
Live-streaming/app/danmu_queue.py
T
2026-08-15 14:43:56 +08:00

10206 lines
464 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
BetterGI 弹幕排队系统
====================
直播排队玩法:
- 观众发弹幕自动创建账号(5积分),每日签到随机+5~10(上限30,凌晨4点重置)
- 发"排队"加入队列,队首可发"上号"触发扫码上号配置组
- 扫码状态变为已登录并确认账号后,队首可发"执行 组名"触发BGI
- 队首确认账号后每分钟扣1积分,积分可扣到负分,不因耗尽中断BGI
- 配置组跑完后按规则保留或出队 → 下一用户90秒上号窗口
- 队列空时跑默认"薄荷"配置组(不扣积分)
指令列表:
排队 - 加入排队队列
签到 - 每日签到(随机+5~10积分,凌晨4点重置)
上号 - 队首触发扫码上号配置组
执行 <组名> - 已确认账号的队首触发配置组(如: 执行 泡泡桔)
跑 <组名> - 兼容旧指令,等同于执行
退出 - 退出排队队列(三级队首不可用)
重置 - 一级用户重启原神和BetterGI
积分 - 查询自己的积分
队列 - 查看当前排队情况
帮助 - 显示帮助
"""
from __future__ import annotations
import asyncio
import argparse
import base64
import json
import logging
import os
import struct
import subprocess
import sys
import time
import ctypes
import ctypes.wintypes
import contextvars
import threading
import shutil
import urllib.request
import urllib.parse
import http.cookies
import hashlib
import heapq
import queue as thread_queue
import re
import difflib
import random
import zlib
import socket
import tempfile
import traceback
import uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK"
def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]:
"""递归删除当前 Windows 用户的 miHoYoSDK 注册表键;键不存在时视为成功。"""
if os.name != "nt":
return {"success": False, "deleted": False, "error": "仅支持 Windows"}
try:
import winreg
def delete_tree(parent, key_path: str) -> None:
try:
with winreg.OpenKey(parent, key_path, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
children = []
index = 0
while True:
try:
children.append(winreg.EnumKey(key, index))
index += 1
except OSError:
break
for child in children:
delete_tree(parent, f"{key_path}\\{child}")
winreg.DeleteKey(parent, key_path)
except FileNotFoundError:
return
try:
winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey, 0, winreg.KEY_READ).Close()
except FileNotFoundError:
logger.info(r"[登录] 注册表 HKCU\Software\miHoYoSDK 不存在,无需清理")
return {"success": True, "deleted": False, "message": "注册表键不存在,无需清理"}
delete_tree(winreg.HKEY_CURRENT_USER, subkey)
logger.info(r"[登录] 已删除注册表 HKCU\Software\miHoYoSDK")
return {"success": True, "deleted": True, "message": "miHoYoSDK 注册表已删除"}
except Exception as exc:
logger.exception(r"[登录] 删除注册表 HKCU\Software\miHoYoSDK 失败")
return {"success": False, "deleted": False, "error": str(exc)}
APP_DIR = Path(__file__).resolve().parent
if str(APP_DIR) not in sys.path:
sys.path.insert(0, str(APP_DIR))
from admin_auth import AdminAuthManager, SESSION_COOKIE_NAME
from admin_events import AdminEventBus
from bilibili_cookie_refresh import BilibiliCookieRefresher, BilibiliCredentialStore, BilibiliQrLogin
from faster_qwen_worker import FasterQwenWorkerClient
from mpv_player import MpvPlayer
from netease_qr_login import NeteaseQrLogin
from netease_resolver import NeteaseResolver
from redemption_codes import RedemptionCodeStore
from stats_store import StatsStore, business_date as statistics_business_date
from core.runtime_paths import (
APP_ROOT as PROJECT_ROOT,
CONFIG_DIR,
DATA_DIR,
DOTS_TTS_SRC,
INTEGRATIONS_DIR,
LOG_DIR,
WEB_DIR,
ensure_runtime_dirs,
project_path,
)
if DOTS_TTS_SRC.exists() and str(DOTS_TTS_SRC) not in sys.path:
sys.path.insert(0, str(DOTS_TTS_SRC))
def _add_ipv4(addresses: list[str], ip: str | None) -> None:
if not ip or ip in addresses:
return
if ip.startswith(("127.", "169.254.")) or ip == "0.0.0.0":
return
parts = ip.split(".")
if len(parts) != 4 or not all(part.isdigit() and 0 <= int(part) <= 255 for part in parts):
return
first, second = int(parts[0]), int(parts[1])
is_lan = first == 10 or (first == 172 and 16 <= second <= 31) or (first == 192 and second == 168)
if is_lan:
addresses.append(ip)
def _ipv4_priority(ip: str) -> tuple[int, str]:
if ip.startswith("192.168."):
return (0, ip)
if ip.startswith("10."):
return (1, ip)
return (2, ip)
def discover_lan_ipv4_addresses() -> list[str]:
addresses: list[str] = []
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("8.8.8.8", 80))
_add_ipv4(addresses, sock.getsockname()[0])
except OSError:
pass
try:
for item in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
_add_ipv4(addresses, item[4][0])
except OSError:
pass
return sorted(addresses, key=_ipv4_priority)
def web_access_urls(host: str, port: int) -> dict:
host = host or "0.0.0.0"
local_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
local = {
"frontend": f"http://{local_host}:{port}/",
"admin": f"http://{local_host}:{port}/admin",
}
lan_ips = [] if host in {"127.0.0.1", "localhost"} else discover_lan_ipv4_addresses()
return {
"bind": f"{host}:{port}",
"local": local,
"lan_frontend": [f"http://{ip}:{port}/" for ip in lan_ips],
"lan_admin": [f"http://{ip}:{port}/admin" for ip in lan_ips],
}
ROLE_SUPER_ADMIN = "super_admin"
ROLE_ACTIVE_OPERATOR = "active_operator"
ROLE_PENDING_OPERATOR = "pending_operator"
ROLE_VIEWER = "viewer"
ALL_DANMU_ROLES = [
ROLE_SUPER_ADMIN,
ROLE_ACTIVE_OPERATOR,
ROLE_PENDING_OPERATOR,
ROLE_VIEWER,
]
COMMAND_ALLOWED_ROLE_DEFAULTS = {
"queue": ALL_DANMU_ROLES,
"signin": ALL_DANMU_ROLES,
"login": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
"confirm_yes": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
"confirm_no": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
"run": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR],
"leave": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR, ROLE_VIEWER],
"reset": [ROLE_SUPER_ADMIN],
"points": ALL_DANMU_ROLES,
"queue_list": ALL_DANMU_ROLES,
"help": ALL_DANMU_ROLES,
}
def normalize_allowed_roles(value: Any, default: list[str]) -> list[str]:
if not isinstance(value, list):
return list(default)
roles = [str(item).strip() for item in value if str(item).strip() in ALL_DANMU_ROLES]
return roles or list(default)
def default_rule_allowed_roles(rule: dict[str, Any]) -> list[str]:
if isinstance(rule.get("allowed_roles"), list):
return normalize_allowed_roles(rule.get("allowed_roles"), ALL_DANMU_ROLES)
if rule.get("admin_only"):
return [ROLE_SUPER_ADMIN]
return list(ALL_DANMU_ROLES)
try:
import websockets
import brotli
import aiohttp
from aiohttp import web
except ImportError:
print("缺少依赖,请运行: pip install websockets brotli aiohttp")
sys.exit(1)
# ============== 常量 ==============
HEADER_LEN = 16
OP_HEARTBEAT = 2
OP_HEARTBEAT_REPLY = 3
OP_MESSAGE = 5
OP_AUTH = 7
OP_AUTH_REPLY = 8
PROTO_JSON = 0
PROTO_ZLIB = 2
PROTO_BROTLI = 3
# 积分相关
INITIAL_POINTS = 10 # 新用户初始积分
SIGNIN_POINTS_MIN = 5 # 每日签到随机积分下限
SIGNIN_POINTS_MAX = 10 # 每日签到随机积分上限
SIGNIN_RESET_HOUR = 4 # 北京时间凌晨4点切换签到业务日
MAX_POINTS = 30 # 积分上限
POINTS_PER_MINUTE = 1 # 每分钟扣除积分
ADMIN_WINDOW_SECONDS = 90 # 90秒上号窗口
LOGIN_TIMEOUT_SECONDS = 240 # 发送“上号”后240秒(4分钟)仍未完成登录则过号
CONFIRM_TIMEOUT_SECONDS = 60 # 扫码成功后1分钟未确认账号则过号
BILIBILI_GOLD_COIN_PER_CNY = 1000.0
BEIJING_TZ = timezone(timedelta(hours=8))
def bilibili_gift_cny_values(coin_type: str, total_coin: Any, quantity: Any) -> tuple[float, float]:
"""将 B 站 SEND_GIFT 的金瓜子金额换算为人民币单价和总价。"""
if str(coin_type or "").strip().casefold() != "gold":
return 0.0, 0.0
try:
raw_total_coin = max(0.0, float(total_coin or 0))
except (TypeError, ValueError):
raw_total_coin = 0.0
try:
count = max(1, int(quantity or 1))
except (TypeError, ValueError):
count = 1
total_value = raw_total_coin / BILIBILI_GOLD_COIN_PER_CNY
return total_value / count, total_value
def _parse_hhmm_minutes(value: str) -> int | None:
text = str(value or "").strip()
if not re.fullmatch(r"\d{2}:\d{2}", text):
return None
try:
parsed = datetime.strptime(text, "%H:%M")
except ValueError:
return None
return parsed.hour * 60 + parsed.minute
def is_within_live_time(system_cfg: dict, now: datetime | None = None) -> bool:
"""判断当前是否位于直播时段,支持例如20:00到次日02:00的跨日区间。"""
start = _parse_hhmm_minutes(system_cfg.get("live_start_time", ""))
end = _parse_hhmm_minutes(system_cfg.get("live_end_time", ""))
if start is None or end is None or start == end:
return False
current = now or datetime.now()
minute = current.hour * 60 + current.minute
if start < end:
return start <= minute < end
return minute >= start or minute < end
def get_signin_business_date(
now: datetime | None = None,
reset_hour: int | None = None,
) -> str:
"""返回签到业务日期;北京时间凌晨 reset_hour 点才进入新的一天。"""
current = now or datetime.now(BEIJING_TZ)
if current.tzinfo is None:
current = current.replace(tzinfo=BEIJING_TZ)
current = current.astimezone(BEIJING_TZ)
hour = SIGNIN_RESET_HOUR if reset_hour is None else max(0, min(23, int(reset_hour)))
return (current - timedelta(hours=hour)).date().isoformat()
# ============== 配置管理 ==============
class Config:
def __init__(self, path: str):
self.path = Path(path)
self.data = {}
self.revision = 0
self.last_error = ""
self.loaded_at = 0.0
self._file_signature: tuple[int, int] = (0, 0)
self.reload()
def _read_file_signature(self) -> tuple[int, int]:
try:
stat = self.path.stat()
except OSError:
return (0, 0)
return (int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))), int(stat.st_size))
def reload(self):
with open(self.path, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("配置文件根节点必须是对象")
self.data = loaded
self._apply_defaults()
self.revision += 1
self.last_error = ""
self.loaded_at = time.time()
self._file_signature = self._read_file_signature()
def reload_if_changed(self) -> bool:
signature = self._read_file_signature()
if signature == (0, 0):
return False
if signature == self._file_signature:
return False
self.reload()
return True
def save(self):
self._apply_defaults()
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
f.write("\n")
tmp.replace(self.path)
self.revision += 1
self.last_error = ""
self.loaded_at = time.time()
self._file_signature = self._read_file_signature()
def update_bilibili_cookies(self, values: dict[str, str]):
"""原子更新 B 站登录 Cookie;不在配置或日志中保存 refresh_token。"""
bili = self.data.setdefault("bilibili", {})
current = http.cookies.SimpleCookie()
try:
current.load(str(bili.get("cookie") or "").replace("; ", ";"))
except Exception:
current = http.cookies.SimpleCookie()
merged = {name: morsel.value for name, morsel in current.items()}
for name, value in values.items():
if value:
merged[str(name)] = str(value)
bili["sessdata"] = merged.get("SESSDATA", str(bili.get("sessdata") or ""))
bili["bili_jct"] = merged.get("bili_jct", str(bili.get("bili_jct") or ""))
bili["buvid3"] = merged.get("buvid3", str(bili.get("buvid3") or ""))
bili["cookie"] = "; ".join(f"{name}={value}" for name, value in merged.items() if value)
self.save()
def apply_runtime_settings(self):
global INITIAL_POINTS, SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX, SIGNIN_RESET_HOUR
global MAX_POINTS, POINTS_PER_MINUTE, ADMIN_WINDOW_SECONDS
q_cfg = self.data.get("queue", {})
INITIAL_POINTS = int(q_cfg.get("initial_points", INITIAL_POINTS))
legacy_signin_points = int(q_cfg.get("signin_points", SIGNIN_POINTS_MAX))
SIGNIN_POINTS_MIN = int(q_cfg.get("signin_points_min", min(5, legacy_signin_points)))
SIGNIN_POINTS_MAX = int(q_cfg.get("signin_points_max", legacy_signin_points))
if SIGNIN_POINTS_MIN > SIGNIN_POINTS_MAX:
SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX = SIGNIN_POINTS_MAX, SIGNIN_POINTS_MIN
SIGNIN_RESET_HOUR = max(0, min(23, int(q_cfg.get("signin_reset_hour", SIGNIN_RESET_HOUR))))
MAX_POINTS = int(q_cfg.get("max_points", MAX_POINTS))
POINTS_PER_MINUTE = int(q_cfg.get("points_per_minute", POINTS_PER_MINUTE))
ADMIN_WINDOW_SECONDS = int(q_cfg.get("admin_window_seconds", ADMIN_WINDOW_SECONDS))
def _apply_defaults(self):
"""补齐旧配置或被部分保存覆盖后的缺省节点,避免启动 KeyError。"""
legacy_frontend = {
key: self.data.pop(key)
for key in ["background_image", "background_opacity", "background_blur", "background_fit"]
if key in self.data
}
self.data.setdefault("bilibili", {})
self.data["bilibili"].setdefault("cookie_auto_refresh_enabled", True)
self.data["bilibili"].setdefault("cookie_check_interval_hours", 6)
self.data.setdefault("bettergi", {})
self.data.setdefault("global", {})
self.data["global"].pop("admin_uidsText", None)
self.data.setdefault("queue", {})
self.data.setdefault("broadcast", {})
broadcast_cfg = self.data["broadcast"]
broadcast_cfg.setdefault("enable_danmu_reply", True)
broadcast_cfg.setdefault("enable_system_danmu", True)
broadcast_cfg.setdefault("enable_tts", True)
broadcast_cfg.setdefault("danmu_interval_sec", 5)
broadcast_cfg.setdefault("tts_provider", "faster-qwen3-tts")
broadcast_cfg.setdefault("tts", {})
broadcast_cfg.setdefault("tts_categories", {})
broadcast_cfg.setdefault("tts_queue", {})
tts_queue = broadcast_cfg["tts_queue"]
tts_queue.setdefault("max_pending", 8)
tts_queue.setdefault("playback_max_pending", 2)
tts_queue.setdefault("max_age_sec", 40.0)
tts_queue.setdefault("urgent_max_age_sec", 60.0)
tts_queue.setdefault("low_priority_max_age_sec", 30.0)
tts_queue.setdefault("warmup_on_start", True)
tts_queue.setdefault("rebuild_before_live", True)
tts_categories = broadcast_cfg["tts_categories"]
for category in (
"signin",
"queue",
"song_request",
"login",
"execution",
"points",
"help",
"reset",
"system",
"gift",
):
tts_categories.setdefault(category, True)
broadcast_cfg.setdefault("gift_thanks", {})
gift_thanks = broadcast_cfg["gift_thanks"]
gift_thanks.setdefault("enabled", True)
gift_thanks.setdefault("tts", True)
gift_thanks.setdefault("danmu", False)
gift_thanks.setdefault("template", "感谢{uname}送出的{num}{gift_name}")
gift_thanks.setdefault("merge_window_sec", 2.0)
gift_thanks.setdefault("dedupe_window_sec", 15.0)
gift_thanks.setdefault("max_pending", 50)
tts_all = broadcast_cfg["tts"]
tts_all.setdefault("faster-qwen3-tts", {})
tts_all["faster-qwen3-tts"].setdefault("device", "cuda")
tts_all["faster-qwen3-tts"].setdefault("model_name_or_path", str(project_path("vendor/tts-model")))
tts_all["faster-qwen3-tts"].setdefault("language", "Chinese")
tts_all["faster-qwen3-tts"].setdefault("ref_audio", str(project_path("data/ref_audio.wav")))
tts_all["faster-qwen3-tts"].setdefault("ref_text", "凯茨莱茵家族的迪奥娜小姐,货物我确实收下了,再次感谢您选择狛荷屋")
tts_all["faster-qwen3-tts"].setdefault("xvec_only", True)
tts_all["faster-qwen3-tts"].setdefault("non_streaming_mode", True)
tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8)
tts_all["faster-qwen3-tts"].setdefault("append_silence", True)
tts_all["faster-qwen3-tts"].setdefault("streaming", True)
self.data.setdefault("frontend", {})
if legacy_frontend:
self.data["frontend"].update(legacy_frontend)
self.data.setdefault("music_monitor", {})
self.data["music_monitor"].pop("targetsText", None)
self.data.setdefault("system", {})
self.data.setdefault("rules", [])
self.data["global"].setdefault("admin_uids", [])
self.data["global"].setdefault("log_level", "INFO")
self.data["queue"].setdefault("default_group", "薄荷")
self.data["queue"].setdefault("data_dir", "data")
# 积分参数补缺省,与全局常量默认值保持一致
self.data["queue"].setdefault("initial_points", 10)
legacy_signin_points = int(self.data["queue"].get("signin_points", 10))
self.data["queue"].setdefault("signin_points_min", min(5, legacy_signin_points))
self.data["queue"].setdefault("signin_points_max", legacy_signin_points)
self.data["queue"].setdefault("signin_reset_hour", 4)
self.data["queue"].pop("signin_points", None)
self.data["queue"].setdefault("max_points", 30)
self.data["queue"].setdefault("points_per_minute", 1)
self.data["queue"].setdefault("admin_window_seconds", 180)
self.data["frontend"].setdefault("background_image", "")
self.data["frontend"].setdefault("background_opacity", 0.65)
self.data["frontend"].setdefault("background_blur", 0)
self.data["frontend"].setdefault("background_fit", "cover")
self.data["frontend"].setdefault("theme", "classic")
self.data["music_monitor"].setdefault("platform", "netease")
self.data["music_monitor"].setdefault("targets", ["网易云音乐", "Netease", "CloudMusic", "cloudmusic"])
self.data["music_monitor"].setdefault("allow_all", False)
self.data["music_monitor"].setdefault("interval_sec", 1.0)
self.data["music_monitor"].setdefault("holdover_ms", 1500)
self.data["music_monitor"].setdefault("prefer_playing", True)
self.data["music_monitor"].setdefault("keep_last_when_none", True)
self.data["music_monitor"].setdefault("cover_enabled", True)
self.data["music_monitor"].setdefault("auto_resume_enabled", False)
self.data["music_monitor"].setdefault("auto_resume_interval_sec", 3)
self.data["music_monitor"].setdefault("auto_resume_stall_sec", 10)
self.data["music_monitor"].setdefault("extra_filter", "")
self.data["music_monitor"].setdefault("request_player", {})
request_cfg = self.data["music_monitor"]["request_player"]
request_cfg.pop("commandsText", None)
request_cfg.setdefault("enabled", True)
request_cfg.setdefault("cost_points", 1)
request_cfg.setdefault("commands", ["点歌", "dg"])
request_cfg.setdefault("api_base", "https://music.163.com")
request_cfg.setdefault("play_url_template", "https://music.163.com/#/song?id={id}")
request_cfg.setdefault("auto_open", True)
request_cfg.setdefault("play_when_idle", True)
# 当前歌曲结束前稍微提前切入队首点歌,避免原列表下一首先播放数秒。
request_cfg.setdefault("handoff_lead_sec", 1.2)
request_cfg.setdefault("max_duration_sec", 600)
request_cfg.setdefault("dedupe_history", True)
# 历史冷却秒数: 同一首歌1小时内不能再点
request_cfg.setdefault("dedupe_cooldown_sec", 3600)
request_cfg.setdefault("clear_on_start", True)
if request_cfg.get("play_method") in {"netease_client_ui", "netease_cdp"}:
request_cfg["play_method"] = "mpv"
request_cfg.setdefault("play_method", "mpv")
request_cfg.setdefault("mpv_exe", "vendor/mpv/mpv.exe")
request_cfg.setdefault("mpv_stall_seconds", 12)
request_cfg.setdefault("fallback_to_netease", False)
request_cfg.setdefault("netease_music_u", "")
request_cfg.setdefault("background_playlist_enabled", False)
request_cfg.setdefault("background_playlist_url", "")
request_cfg.setdefault("background_playlist_refresh_sec", 3600)
request_cfg.setdefault("background_playlist_retry_sec", 30)
request_cfg.setdefault("cdp_port", 9222)
request_cfg.setdefault("auto_launch_cdp", True)
request_cfg.setdefault("client_process", "cloudmusic.exe")
request_cfg.setdefault("client_exe", "")
request_cfg.setdefault("search_hotkey", "ctrl+f")
request_cfg.setdefault("play_enter_count", 2)
request_cfg.setdefault("ui_wait_sec", 0.6)
request_cfg["allowed_roles"] = normalize_allowed_roles(
request_cfg.get("allowed_roles"),
ALL_DANMU_ROLES,
)
system_cfg = self.data["system"]
system_cfg.setdefault("enable_startup_shortcut", True)
system_cfg.setdefault("startup_bat", "run.bat")
# 直播时间是唯一调度入口;旧版独立动作时间仅用于首次迁移。
system_cfg.setdefault("live_start_time", system_cfg.get("bilibili_push_time", "09:00"))
system_cfg.setdefault("live_end_time", system_cfg.get("bilibili_stop_push_time", "23:00"))
system_cfg.setdefault("auto_reboot_enabled", True)
system_cfg.setdefault("auto_reboot_time", "03:00")
system_cfg.setdefault("reboot_after_stop_enabled", False)
system_cfg.setdefault("reboot_after_stop_delay_sec", 60)
system_cfg.setdefault("launch_bilibili_live_enabled", False)
system_cfg.setdefault("launch_bilibili_live_time", "19:30")
system_cfg.setdefault("bilibili_live_exe", "")
system_cfg.setdefault("launch_genshin_enabled", False)
system_cfg.setdefault("launch_genshin_time", "19:40")
system_cfg.setdefault("genshin_exe", "")
system_cfg.setdefault("bilibili_push_enabled", False)
system_cfg.setdefault("bilibili_push_time", "19:50")
system_cfg.setdefault("bilibili_push_window_keyword", "直播姬")
system_cfg.setdefault("bilibili_push_click_x_ratio", 0.787)
system_cfg.setdefault("bilibili_push_click_y_ratio", 0.927)
system_cfg.setdefault("bilibili_stop_push_enabled", False)
system_cfg.setdefault("bilibili_stop_push_time", "23:00")
system_cfg.setdefault("bilibili_stop_push_click_x_ratio", 0.787)
system_cfg.setdefault("bilibili_stop_push_click_y_ratio", 0.927)
system_cfg.setdefault("bilibili_stop_push_confirm_enter", True)
# 内置指令别名配置
self.data.setdefault("commands", {})
cmd_defaults = {
"queue": {"enabled": True, "aliases": ["排队"]},
"signin": {"enabled": True, "aliases": ["签到"]},
"login": {"enabled": True, "aliases": ["上号"]},
"confirm_yes": {"enabled": True, "aliases": ["是"]},
"confirm_no": {"enabled": True, "aliases": ["不是"]},
"run": {"enabled": True, "aliases": ["执行", "跑", "开始"]},
"leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]},
"reset": {"enabled": True, "aliases": ["重置"]},
"points": {"enabled": True, "aliases": ["积分"]},
"queue_list": {"enabled": True, "aliases": ["队列"]},
"help": {"enabled": True, "aliases": ["帮助"]},
}
for cmd_key, cmd_val in cmd_defaults.items():
node = self.data["commands"].setdefault(cmd_key, cmd_val)
# 补全保存后可能丢失的字段
node.setdefault("enabled", cmd_val["enabled"])
node.setdefault("aliases", cmd_val["aliases"])
node["allowed_roles"] = normalize_allowed_roles(
node.get("allowed_roles"),
COMMAND_ALLOWED_ROLE_DEFAULTS.get(cmd_key, ALL_DANMU_ROLES),
)
for rule in self.data["rules"]:
if not isinstance(rule, dict):
continue
rule["allowed_roles"] = default_rule_allowed_roles(rule)
@property
def room_id(self) -> int:
return int(self.data.get("bilibili", {}).get("room_id", 0))
@property
def sessdata(self) -> str:
return str(self.data.get("bilibili", {}).get("sessdata", "") or "").strip()
@property
def bilibili_cookie(self) -> str:
"""构造 API 与 WebSocket 共用的登录 Cookie,保证认证上下文一致。"""
bili = self.data.get("bilibili", {})
explicit = str(bili.get("cookie", "") or "").strip()
if explicit:
return explicit
parts = []
if self.sessdata:
parts.append(f"SESSDATA={self.sessdata}")
bili_jct = str(bili.get("bili_jct", "") or "").strip()
if bili_jct:
parts.append(f"bili_jct={bili_jct}")
buvid3 = str(bili.get("buvid3", "") or "").strip()
if buvid3:
parts.append(f"buvid3={buvid3}")
return "; ".join(parts)
@property
def bettergi_exe(self) -> str:
return self.data.get("bettergi", {}).get("exe_path", "")
@property
def bettergi_work_dir(self) -> str:
wd = self.data.get("bettergi", {}).get("work_dir", "")
return wd if wd else str(Path(self.bettergi_exe).parent)
@property
def default_group(self) -> str:
return self.data.get("queue", {}).get("default_group", "薄荷")
@property
def admin_uids(self) -> set:
return set(int(x) for x in self.data.get("global", {}).get("admin_uids", []))
@property
def data_dir(self) -> str:
return str(project_path(self.data.get("queue", {}).get("data_dir", "data")))
@property
def log_level(self) -> str:
return self.data.get("global", {}).get("log_level", "INFO")
@property
def system_cfg(self) -> dict:
return self.data.setdefault("system", {})
@property
def bili_jct(self) -> str:
bili = self.data.get("bilibili", {})
explicit = str(bili.get("bili_jct", "") or "").strip()
if explicit:
return explicit
sessdata = str(bili.get("sessdata", "") or "")
if "bili_jct=" in sessdata:
try:
cookie = http.cookies.SimpleCookie()
cookie.load(sessdata.replace("; ", ";"))
if "bili_jct" in cookie:
return cookie["bili_jct"].value
except Exception:
pass
return ""
@property
def broadcast_cfg(self) -> dict:
return self.data.get("broadcast", {})
@property
def frontend_cfg(self) -> dict:
return self.data.get("frontend", {})
@property
def music_monitor_cfg(self) -> dict:
return self.data.get("music_monitor", {})
@property
def enable_danmu_reply(self) -> bool:
return self.broadcast_cfg.get("enable_danmu_reply", False)
@property
def enable_tts(self) -> bool:
return self.broadcast_cfg.get("enable_tts", False)
@property
def danmu_interval_sec(self) -> float:
return float(self.broadcast_cfg.get("danmu_interval_sec", 5))
@property
def tts_provider(self) -> str:
if not self.enable_tts:
return "none"
return self.broadcast_cfg.get("tts_provider", "none")
@property
def tts_cfg(self) -> dict:
return self.broadcast_cfg.get("tts", {})
class ServiceRegistry:
"""Runtime service state registry for admin UI and watchdogs."""
STARTING = "STARTING"
RUNNING = "RUNNING"
DEGRADED = "DEGRADED"
RECONNECTING = "RECONNECTING"
STOPPING = "STOPPING"
STOPPED = "STOPPED"
FAILED = "FAILED"
def __init__(self, stats_store: StatsStore | None = None):
self._services: dict[str, dict] = {}
self._lock = threading.Lock()
self.stats_store = stats_store
def set(self, name: str, state: str, message: str = "", error: str = ""):
now = time.time()
with self._lock:
item = self._services.setdefault(name, {
"name": name,
"state": self.STARTING,
"message": "",
"error": "",
"restarts": 0,
"updated_at": now,
})
old_state = item.get("state")
old_message = item.get("message", "")
old_error = item.get("error", "")
item["state"] = state
item["message"] = message
item["error"] = error
item["updated_at"] = now
if self.stats_store and (old_state, old_message, old_error) != (state, message, error):
self.stats_store.record_service_state(
name,
state,
old_state=old_state,
reason=message or None,
payload={"error": error} if error else {},
)
def bump_restart(self, name: str):
with self._lock:
item = self._services.setdefault(name, {
"name": name,
"state": self.STARTING,
"message": "",
"error": "",
"restarts": 0,
"updated_at": time.time(),
})
current_state = item.get("state")
item["restarts"] = int(item.get("restarts", 0)) + 1
restart_count = item["restarts"]
item["updated_at"] = time.time()
if self.stats_store:
self.stats_store.record_service_state(
name,
"restart",
old_state=current_state,
reason="watchdog_restart",
payload={"restart_count": restart_count},
)
def snapshot(self) -> list[dict]:
with self._lock:
return [dict(v) for v in sorted(self._services.values(), key=lambda x: x["name"])]
def summary(self) -> str:
states = {item["state"] for item in self.snapshot()}
if self.FAILED in states:
return self.FAILED
if self.DEGRADED in states:
return self.DEGRADED
if self.RECONNECTING in states:
return self.RECONNECTING
if self.STARTING in states:
return self.STARTING
if states and states <= {self.RUNNING, self.STOPPED}:
return self.RUNNING
return self.STOPPED
# ============== 用户管理 ==============
class UserManager:
"""用户账号与积分管理,持久化到 data/users.json"""
def __init__(self, data_dir: str, logger: logging.Logger, config: "Config | None" = None,
stats_store: StatsStore | None = None):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.users_path = self.data_dir / "users.json"
self.logger = logger
self.config = config # resolve_uname 用它读 sessdata
self.stats_store = stats_store
self.users: dict = {}
self._lock = asyncio.Lock()
self._points_backfill_needed = False
self._load()
def _load(self):
if self.users_path.exists():
try:
with open(self.users_path, "r", encoding="utf-8") as f:
self.users = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
self.logger.error(f"[用户] 读取 users.json 失败,备份后重建: {exc}")
try:
backup = self.users_path.with_suffix(".corrupt-" + datetime.now().strftime("%Y%m%d-%H%M%S"))
self.users_path.replace(backup)
except Exception:
pass
self.users = {}
dirty = False
for uid_str, user in list(self.users.items()):
if not isinstance(user, dict):
self.users[uid_str] = self._default_user(uid_str, f"用户{uid_str}")
dirty = True
continue
before = dict(user)
self._normalize_user(user)
dirty = dirty or before != user
# 积分权威在 sqlite:统计库已有积分数据时覆盖 users.json,否则整体回填。
if self.stats_store is not None:
sqlite_points = self.stats_store.load_user_points("bilibili")
if not sqlite_points:
self._points_backfill_needed = True
else:
for uid_str, pts in sqlite_points.items():
user = self.users.get(uid_str)
if user is None:
user = self._default_user(uid_str, f"用户{uid_str}")
self.users[uid_str] = user
user["points"] = pts
if any(uid not in sqlite_points for uid in self.users):
self._points_backfill_needed = True
if dirty:
asyncio.create_task(self._save())
def _default_user(self, uid_str: str, uname: str) -> dict:
return {
"uname": uname,
"points": INITIAL_POINTS,
"last_signin_date": "",
"created_at": datetime.now().isoformat(),
"blocked_all": False,
"blocked_queue": False,
"blocked_song_request": False,
"note": "",
}
def _normalize_user(self, user: dict) -> dict:
user.setdefault("uname", "")
user.setdefault("points", INITIAL_POINTS)
user.setdefault("last_signin_date", "")
user.setdefault("created_at", datetime.now().isoformat())
user.setdefault("blocked_all", False)
user.setdefault("blocked_queue", False)
user.setdefault("blocked_song_request", False)
user.setdefault("note", "")
return user
async def _save(self):
async with self._lock:
tmp = self.users_path.with_suffix(".tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.users, f, ensure_ascii=False, indent=2)
tmp.replace(self.users_path)
except OSError as exc:
# 积分已由 sqlite 权威持久化,users.json 仅作缓存,保存失败不致命。
self.logger.error(f"[用户] 保存 users.json 失败(积分已持久化到 sqlite): {exc}")
async def backfill_points_to_sqlite(self):
"""首次迁移或补漏:把内存积分整体写入 sqlite 权威余额。"""
if not self.stats_store or not self._points_backfill_needed:
return
for uid_str, user in list(self.users.items()):
await self.stats_store.set_user_points("bilibili", uid_str, int(user.get("points", 0) or 0))
self._points_backfill_needed = False
def ensure_user(self, uid: int, uname: str) -> dict:
"""确保有效 UID 用户存在;匿名 uid=0 不得进入积分/队列账户。"""
if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
raise ValueError(f"无效或匿名 UID: {uid!r}")
uid_str = str(uid)
created = uid_str not in self.users
if created:
self.users[uid_str] = self._default_user(uid_str, uname)
self.logger.info(f"[新用户] {uname}({uid}) 创建账号, 赠送{INITIAL_POINTS}积分")
asyncio.create_task(self._save())
if self.stats_store:
asyncio.create_task(self.stats_store.set_user_points("bilibili", uid_str, INITIAL_POINTS))
else:
# 更新昵称,但不允许平台脱敏昵称覆盖已经保存的真实昵称。
old_uname = str(self.users[uid_str].get("uname", "") or "")
if uname and uname != old_uname and ("*" not in uname or not old_uname or "*" in old_uname):
self.users[uid_str]["uname"] = uname
user = self._normalize_user(self.users[uid_str])
if self.stats_store:
self.stats_store.upsert_user_snapshot(
"bilibili",
uid_str,
display_name=user.get("uname"),
snapshot={
"points": user.get("points"),
"created_at": user.get("created_at"),
"blocked_all": user.get("blocked_all"),
"blocked_queue": user.get("blocked_queue"),
"blocked_song_request": user.get("blocked_song_request"),
"note": user.get("note", ""),
},
)
if created:
self.stats_store.record_point_transaction(
f"user-created:{uid_str}",
INITIAL_POINTS,
platform="bilibili",
platform_user_id=uid_str,
balance_after=INITIAL_POINTS,
reason="account_created",
)
return user
def get_user(self, uid: int) -> dict:
if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
raise ValueError(f"无效或匿名 UID: {uid!r}")
return self._normalize_user(self.users.setdefault(str(uid), self._default_user(str(uid), f"用户{uid}")))
def get_points(self, uid: int) -> int:
return self.users.get(str(uid), {}).get("points", 0)
def has_block(self, uid: int, scope: str) -> bool:
user = self.users.get(str(uid), {})
if user.get("blocked_all"):
return True
if scope == "queue":
return bool(user.get("blocked_queue"))
if scope == "song_request":
return bool(user.get("blocked_song_request"))
return False
def _is_masked_uname(self, uname: str) -> bool:
return not uname or "*" in uname
async def resolve_uname(self, uid: int, fallback: str = "") -> str:
"""尽量用 B站公开接口按 uid 补全昵称。若平台返回仍脱敏/不可查,则保留 fallback。"""
if not uid or uid <= 0:
return fallback
uid_str = str(uid)
cached = self.users.get(uid_str, {}).get("uname", "")
if cached and not self._is_masked_uname(cached):
return cached
fallback = fallback or cached or f"用户{uid}"
urls = [
f"https://api.bilibili.com/x/space/wbi/acc/info?mid={uid}",
f"https://api.bilibili.com/x/space/acc/info?mid={uid}",
f"https://api.bilibili.com/x/web-interface/card?mid={uid}",
]
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com/"}
sessdata = getattr(self.config, "sessdata", "") if self.config else ""
if sessdata:
headers["Cookie"] = f"SESSDATA={sessdata}"
# 同步网络查询放到线程池,避免阻塞事件循环(弹幕处理热路径)。
name = await asyncio.to_thread(self._resolve_uname_blocking, urls, headers, uid)
if name:
if uid_str in self.users:
self.users[uid_str]["uname"] = name
await self._save()
self.logger.info(f"[昵称补全] {fallback}({uid}) -> {name}")
return name
return fallback
def _resolve_uname_blocking(self, urls: list[str], headers: dict, uid: int) -> str:
for url in urls:
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
if data.get("code") != 0:
continue
node = data.get("data", {}) or {}
names = [node.get("name")]
if isinstance(node.get("card"), dict):
names.append(node["card"].get("name"))
for name in names:
if isinstance(name, str) and name and not self._is_masked_uname(name):
return name
except Exception as e:
self.logger.debug(f"[昵称补全] 查询 {uid} 失败: {e}")
return ""
async def add_points(
self,
uid: int,
delta: int,
*,
reason: str = "unspecified",
reference_type: str = "",
reference_id: str = "",
transaction_id: str | None = None,
):
"""增减积分(可为负),保存成功后旁路记录积分流水。返回新积分。"""
uid_str = str(uid)
if uid_str not in self.users:
return 0
new_points = self.users[uid_str]["points"] + delta
self.users[uid_str]["points"] = new_points
if self.stats_store:
await self.stats_store.set_user_points("bilibili", uid_str, new_points)
await self._save()
if self.stats_store:
self.stats_store.record_point_transaction(
transaction_id or uuid.uuid4().hex,
delta,
platform="bilibili",
platform_user_id=uid_str,
balance_after=new_points,
reason=reason,
reference_type=reference_type or None,
reference_id=reference_id or None,
)
user = self.users[uid_str]
self.stats_store.upsert_user_snapshot(
"bilibili",
uid_str,
display_name=user.get("uname"),
snapshot=user,
)
return new_points
async def signin(self, uid: int) -> dict:
"""每日签到。返回 {"success": bool, "msg": str, "points": int}"""
uid_str = str(uid)
if uid_str not in self.users:
return {"success": False, "msg": "用户不存在", "points": 0}
business_date = get_signin_business_date()
user = self.users[uid_str]
if user.get("last_signin_date") == business_date:
return {"success": False, "msg": "本签到周期已签到", "points": user["points"]}
user["last_signin_date"] = business_date
old = user["points"]
reward = random.randint(SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX)
# 积分已达/超上限(如兑换码超限)时,签到不增加也不减少
if old >= MAX_POINTS:
user["points"] = old
else:
user["points"] = min(old + reward, MAX_POINTS)
if self.stats_store:
await self.stats_store.set_user_points("bilibili", uid_str, user["points"])
await self._save()
gained = user["points"] - old
if self.stats_store:
signin_event_id = f"signin:{business_date}:{uid_str}"
self.stats_store.record_signin(
signin_event_id,
platform="bilibili",
platform_user_id=uid_str,
points_awarded=gained,
status="success",
)
if gained:
self.stats_store.record_point_transaction(
f"signin-points:{business_date}:{uid_str}",
gained,
platform="bilibili",
platform_user_id=uid_str,
balance_after=user["points"],
reason="signin_reward",
reference_type="signin",
reference_id=signin_event_id,
)
self.stats_store.upsert_user_snapshot(
"bilibili",
uid_str,
display_name=user.get("uname"),
snapshot=user,
)
if gained > 0:
msg = f"签到成功 +{gained}积分 (当前{user['points']}/{MAX_POINTS})"
else:
msg = f"签到成功,积分已达上限不增加 (当前{user['points']}/{MAX_POINTS})"
return {
"success": True,
"msg": msg,
"points": user["points"],
}
async def update_flags(
self,
uid: int,
*,
blocked_all: bool | None = None,
blocked_queue: bool | None = None,
blocked_song_request: bool | None = None,
note: str | None = None,
) -> dict:
user = self.get_user(uid)
if blocked_all is not None:
user["blocked_all"] = bool(blocked_all)
if blocked_queue is not None:
user["blocked_queue"] = bool(blocked_queue)
if blocked_song_request is not None:
user["blocked_song_request"] = bool(blocked_song_request)
if note is not None:
user["note"] = str(note)
await self._save()
if self.stats_store:
self.stats_store.upsert_user_snapshot(
"bilibili",
str(uid),
display_name=user.get("uname"),
snapshot=user,
)
return user
# ============== 队列管理 ==============
class QueueManager:
"""排队队列管理。持久化到 data/queue_state.json"""
def __init__(self, data_dir: str, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.state_path = self.data_dir / "queue_state.json"
self.logger = logger
self.stats_store = stats_store
self.state = {
"queue": [], # [uid, uid, ...]
"queue_joined_at": {}, # uid -> UTC ISO,本轮排队等待起点
"current_admin_uid": None, # 当前临时管理员UID
"current_group": None, # 当前运行的配置组名
"current_group_run_id": None, # 当前配置组任务实例编号,用于拒绝迟到/重复完成回调
"group_start_time": None, # ISO时间戳
"admin_window_end": None, # 90秒窗口结束时间戳(ISO)
"default_running": False, # 是否在跑默认薄荷
"login_status": None, # None/logining/confirming/logged_in
"login_session_id": None, # 本轮扫码登录稳定会话编号
"login_started_at": None, # 本轮扫码上号开始时间戳
"confirm_started_at": None, # 本轮账号确认开始时间戳
"billing_started_at": None, # 确认账号后开始扣积分的时间戳
"billing_last_at": None, # 上次扣积分时间戳
"billing_uid": None, # 当前正在计费的用户;积分耗尽出队后仍继续扣到负分
"last_login_remind_at": None, # 上号/确认阶段上次提醒时间戳
"reset_on_next_login_uid": None, # 登录超时后,下一位发送“上号”时先执行统一重置
"has_user_finished_once": False, # 至少一个用户任务结束后才允许空闲薄荷
}
self._load()
# 每次重新启动直播视为新会话:清空上次遗留的排队、队首、扫码登录和运行状态。
# 用户账号/积分保留在 users.json;这里只清理 queue_state.json 的运行态。
self.state["queue"] = []
self.state["queue_joined_at"] = {}
self.state["current_admin_uid"] = None
self.state["current_group"] = None
self.state["current_group_run_id"] = None
self.state["group_start_time"] = None
self.state["admin_window_end"] = None
self.state["default_running"] = False
self.state["login_status"] = None
self.state["login_session_id"] = None
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
self.state["last_login_remind_at"] = datetime.now().timestamp()
self.state["reset_on_next_login_uid"] = None
self.state["has_user_finished_once"] = False
self._save()
def _load(self):
if self.state_path.exists():
with open(self.state_path, "r", encoding="utf-8") as f:
saved = json.load(f)
self.state.update(saved)
def _save(self):
tmp = self.state_path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.state, f, ensure_ascii=False, indent=2)
tmp.replace(self.state_path)
def interrupt_group(
self,
reason: str,
*,
status: str = "interrupted",
expected_run_id: str | None = None,
clear_state: bool = True,
) -> dict:
"""幂等收口当前配置组,供主动停止、替换和异常结束路径统一使用。"""
run_id = self.state.get("current_group_run_id")
group_name = self.state.get("current_group")
if not run_id or (expected_run_id is not None and run_id != expected_run_id):
return {"accepted": False, "run_id": run_id, "group_name": group_name}
final_status = status if status in {"cancelled", "interrupted", "failed"} else "interrupted"
payload = {
"reason": str(reason or "unspecified"),
"default_running": bool(self.state.get("default_running")),
"billing_uid": self.state.get("billing_uid"),
}
if clear_state:
self.state["current_group"] = None
self.state["current_group_run_id"] = None
self.state["group_start_time"] = None
self.state["default_running"] = False
self._save()
if self.stats_store:
self.stats_store.record_group_run(
str(run_id),
group_name=group_name,
status=final_status,
ended_at_utc=datetime.now(timezone.utc).isoformat(),
failed_count=1 if final_status == "failed" else 0,
payload=payload,
)
self.logger.info(
f"[配置组] 已收口运行实例: group='{group_name}', run_id={run_id}, "
f"status={final_status}, reason={reason}"
)
return {"accepted": True, "run_id": run_id, "group_name": group_name, "status": final_status}
def join_queue(self, uid: int) -> dict:
"""加入队列。返回 {"success": bool, "msg": str, "position": int}"""
if uid in self.state["queue"]:
pos = self.state["queue"].index(uid) + 1
return {"success": False, "msg": f"已在队列中(第{pos}位)", "position": pos}
joined_at = datetime.now(timezone.utc)
self.state["queue"].append(uid)
self.state.setdefault("queue_joined_at", {})[str(uid)] = joined_at.isoformat()
pos = len(self.state["queue"])
self._save()
if self.stats_store:
self.stats_store.record_queue_event(
"main",
"join",
item_id=str(uid),
position=pos,
queue_size=len(self.state["queue"]),
payload={"platform": "bilibili", "platform_user_id": str(uid)},
)
# 如果是第一个,自动成为临时管理员,开启90秒窗口
if pos == 1 and self.state["current_admin_uid"] is None:
self._set_admin(uid)
return {"success": True, "msg": f"已加入队列(第{pos}位)", "position": pos}
def leave_queue(self, uid: int, *, action: str = "leave", reason: str = "") -> dict:
"""退出队列,并在现有状态保存成功后记录离队原因。"""
if uid not in self.state["queue"]:
return {"success": False, "msg": "不在队列中",
"was_admin": False, "was_running": False}
old_position = self.state["queue"].index(uid) + 1
was_admin = (uid == self.state["current_admin_uid"])
preserve_default_group = bool(was_admin and self.state.get("default_running"))
was_running = (was_admin
and self.state["current_group"] is not None
and not self.state["default_running"])
login_session_id = self.state.get("login_session_id") if was_admin else None
if was_running:
self.interrupt_group(reason or action, status="cancelled")
self.state["queue"].remove(uid)
self.state.setdefault("queue_joined_at", {}).pop(str(uid), None)
if was_admin:
# 队首退出:清空配置组/登录状态,提升下一个
self.state["current_admin_uid"] = None
if not preserve_default_group:
self.state["current_group"] = None
self.state["current_group_run_id"] = None
self.state["group_start_time"] = None
self.state["admin_window_end"] = None
self.state["login_status"] = None
self.state["login_session_id"] = None
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
self._promote_next()
self._save()
if self.stats_store and login_session_id:
self.stats_store.record_login_session(
login_session_id,
status="cancelled",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
metadata_json=json.dumps({"reason": reason or action}, ensure_ascii=False),
)
if self.stats_store:
self.stats_store.record_queue_event(
"main",
action,
item_id=str(uid),
position=old_position,
queue_size=len(self.state["queue"]),
payload={
"platform": "bilibili",
"platform_user_id": str(uid),
"reason": reason,
"was_admin": was_admin,
"was_running": was_running,
"promoted_uid": self.state.get("current_admin_uid") if was_admin else None,
},
)
return {"success": True, "msg": "已退出队列",
"was_admin": was_admin, "was_running": was_running}
def _set_admin(self, uid: int):
"""设置队首并开启90秒上号窗口"""
self.state["current_admin_uid"] = uid
self.state["login_status"] = None
self.state["login_session_id"] = None
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
self.state["last_login_remind_at"] = datetime.now().timestamp()
if self.state.get("reset_on_next_login_uid") not in (None, uid):
self.state["reset_on_next_login_uid"] = None
self.state["admin_window_end"] = (
datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
)
self.logger.info(f"[队列] {uid} 成为队首, 90秒上号窗口开启")
joined_at_raw = self.state.setdefault("queue_joined_at", {}).pop(str(uid), None)
promoted_at = datetime.now(timezone.utc)
wait_ms = None
if joined_at_raw:
try:
joined_at = datetime.fromisoformat(str(joined_at_raw).replace("Z", "+00:00"))
if joined_at.tzinfo is None:
joined_at = joined_at.replace(tzinfo=timezone.utc)
wait_ms = max(0, int((promoted_at - joined_at.astimezone(timezone.utc)).total_seconds() * 1000))
except (TypeError, ValueError):
self.logger.warning(f"[队列] {uid} 的入队时间无效,跳过等待时长统计")
if self.stats_store:
self.stats_store.record_queue_event(
"main",
"promoted",
item_id=str(uid),
occurred_at_utc=promoted_at.isoformat(),
position=1,
queue_size=len(self.state["queue"]),
wait_ms=wait_ms,
payload={"platform": "bilibili", "platform_user_id": str(uid)},
)
self._save()
def touch_admin(self, uid: int) -> bool:
"""队首任意弹幕续期90秒窗口。"""
if uid != self.state["current_admin_uid"]:
return False
if self.state["current_group"] is not None:
return False
if self.state.get("login_status") == "logining":
return False
self.state["admin_window_end"] = (
datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
)
self._save()
return True
def _promote_next(self):
"""提升下一个用户为临时管理员"""
if self.state["queue"]:
next_uid = self.state["queue"][0]
self._set_admin(next_uid)
else:
self.state["current_admin_uid"] = None
self.state["admin_window_end"] = None
def remove_current_admin_keep_running(self, *, action: str = "leave", reason: str = "") -> dict:
"""移出当前计费用户并提升下一位,但不清 current_group、不停止 BGI。
适用于积分耗尽或用户在配置组执行中主动退出。running_group 继续自然运行;
billing_uid 继续指向被移出的用户并继续计费。下一位成为队首后可发送“上号”
抢占,此时由 _cmd_login 负责停止旧 BetterGI 任务。
"""
old_uid = self.state.get("current_admin_uid")
if not old_uid:
return {"success": False, "old_uid": None, "promoted_uid": None}
login_session_id = self.state.get("login_session_id")
running_group = self.state.get("current_group")
running_group_run_id = self.state.get("current_group_run_id")
group_start_time = self.state.get("group_start_time")
billing_started_at = self.state.get("billing_started_at")
billing_last_at = self.state.get("billing_last_at")
if old_uid in self.state["queue"]:
self.state["queue"].remove(old_uid)
self.state.setdefault("queue_joined_at", {}).pop(str(old_uid), None)
self.state["current_admin_uid"] = None
self.state["login_status"] = None
self.state["login_session_id"] = None
self.state["admin_window_end"] = None
self._promote_next()
# _set_admin 会清空计费字段;这里恢复旧用户计费与当前运行组,保证 BGI 继续跑且继续扣旧用户负分。
self.state["current_group"] = running_group
self.state["current_group_run_id"] = running_group_run_id
self.state["group_start_time"] = group_start_time
self.state["billing_uid"] = old_uid
self.state["billing_started_at"] = billing_started_at or datetime.now().isoformat()
self.state["billing_last_at"] = billing_last_at or datetime.now().isoformat()
self._save()
if self.stats_store and login_session_id:
self.stats_store.record_login_session(
login_session_id,
status="cancelled",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
metadata_json=json.dumps({"reason": reason or action}, ensure_ascii=False),
)
if self.stats_store:
self.stats_store.record_queue_event(
"main",
action,
item_id=str(old_uid),
position=1,
queue_size=len(self.state["queue"]),
payload={
"platform": "bilibili",
"platform_user_id": str(old_uid),
"reason": reason,
"running_group": running_group,
"group_run_id": running_group_run_id,
"promoted_uid": self.state.get("current_admin_uid"),
},
)
return {
"success": True,
"old_uid": old_uid,
"promoted_uid": self.state.get("current_admin_uid"),
"running_group": running_group,
}
def start_login(self, uid: int) -> dict:
"""队首触发扫码上号。返回 {"success": bool, "msg": str}"""
if uid != self.state["current_admin_uid"]:
return {"success": False, "msg": "你不是队首"}
if self.state["login_status"] == "logged_in":
return {"success": False, "msg": "已登录,请发\"执行 组名\""}
if self.state["login_status"] == "confirming":
return {"success": False, "msg": "请先回复\"\"\"不是\"确认账号"}
if self.state["current_group"] is not None:
return {"success": False, "msg": "当前有配置组在运行"}
now = datetime.now().isoformat()
login_session_id = uuid.uuid4().hex
self.state["login_status"] = "logining"
self.state["login_session_id"] = login_session_id
self.state["login_started_at"] = now
self.state["confirm_started_at"] = None
self.state["admin_window_end"] = None
self.state["last_login_remind_at"] = datetime.now().timestamp()
self._save()
if self.stats_store:
self.stats_store.record_login_session(
login_session_id,
platform="bilibili",
platform_user_id=str(uid),
status="logining",
client_kind="bettergi_qr",
metadata_json=json.dumps({"queue_position": 1}, ensure_ascii=False),
)
return {"success": True, "msg": "扫码上号已启动", "login_session_id": login_session_id}
def set_login_status(self, status: str | None, *, reason: str = ""):
"""更新登录状态,并复用 login_session_id 推进登录生命周期。"""
now_iso = datetime.now().isoformat()
login_session_id = self.state.get("login_session_id")
self.state["login_status"] = status
if status in ("logining", "confirming"):
self.state["last_login_remind_at"] = datetime.now().timestamp()
if status == "logining":
self.state["login_started_at"] = self.state.get("login_started_at") or now_iso
self.state["confirm_started_at"] = None
elif status == "confirming":
self.state["confirm_started_at"] = now_iso
elif status == "logged_in":
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
if status in ("confirming", "logged_in"):
self.state["admin_window_end"] = None
elif status is None and self.state.get("current_admin_uid"):
self.state["admin_window_end"] = (
datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
)
if status == "logged_in" and not self.state.get("billing_started_at"):
self.state["billing_started_at"] = now_iso
self.state["billing_last_at"] = now_iso
self.state["billing_uid"] = self.state.get("current_admin_uid")
if status is None:
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
if status not in ("logining", "confirming"):
self.state["last_login_remind_at"] = datetime.now().timestamp()
self._save()
if self.stats_store and login_session_id:
fields: dict[str, Any] = {
"status": status or "cancelled",
"metadata_json": json.dumps({"reason": reason}, ensure_ascii=False),
}
if status in ("logged_in", None):
fields["ended_at_utc"] = datetime.now(timezone.utc).isoformat()
self.stats_store.record_login_session(login_session_id, **fields)
if status in ("logged_in", None):
self.state["login_session_id"] = None
self._save()
def start_group(self, uid: int, group_name: str, run_id: str | None = None) -> dict:
"""已登录队首执行配置组。每次运行绑定唯一任务实例编号。"""
if uid != self.state["current_admin_uid"]:
return {"success": False, "msg": "你不是队首"}
if self.state["login_status"] != "logged_in":
return {"success": False, "msg": "请先发\"上号\"完成扫码登录"}
run_id = str(run_id or uuid.uuid4().hex)
self.state["current_group"] = group_name
self.state["current_group_run_id"] = run_id
self.state["default_running"] = False
self.state["group_start_time"] = datetime.now().isoformat()
self.state["admin_window_end"] = None # 运行中不需要窗口
if not self.state.get("billing_started_at"):
now = datetime.now().isoformat()
self.state["billing_started_at"] = now
self.state["billing_last_at"] = now
self.state["billing_uid"] = uid
self._save()
if self.stats_store:
self.stats_store.record_group_run(
run_id,
group_name=group_name,
status="running",
payload={
"platform": "bilibili",
"platform_user_id": str(uid),
"default_running": False,
},
)
return {"success": True, "msg": f"开始执行配置组: {group_name}", "run_id": run_id}
def group_finished(self, expected_run_id: str | None, keep_current: bool = False) -> dict:
"""配置组运行完成。
keep_current=True 表示本次配置组 3 分钟内结束,当前队首不出队,保留二级权限继续执行。
返回 {"accepted": bool, "need_default": bool, "finished_uid": int|None, "promoted_uid": int|None, "kept": bool}
"""
current_run_id = self.state.get("current_group_run_id")
if not expected_run_id or current_run_id != expected_run_id:
return {
"accepted": False,
"reason": "stale_or_duplicate",
"expected_run_id": expected_run_id,
"current_run_id": current_run_id,
"need_default": False,
"finished_uid": None,
"promoted_uid": None,
"kept": False,
}
# 同步消费该实例;此方法内没有 await,可保证自然完成与 watchdog 只能有一个入口结算成功。
self.state["current_group_run_id"] = None
was_default_running = bool(self.state.get("default_running"))
current_admin_uid = self.state.get("current_admin_uid")
if was_default_running:
# 默认组可以和“待上号队首”并存。默认组完成只能收口自身,不能把刚入队的
# current_admin_uid 当成任务执行者,否则会在入队与停止默认组的竞态中误踢队首。
self.state["current_group"] = None
self.state["group_start_time"] = None
self.state["default_running"] = False
need_default = self.state.get("login_status") is None
self._save()
if self.stats_store:
self.stats_store.record_group_run(
current_run_id,
status="completed",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
completed_count=1,
payload={"default_running": True},
)
return {
"accepted": True,
"need_default": need_default,
"finished_uid": None,
"promoted_uid": None,
"kept": False,
"was_default_running": True,
}
# current_group 可能属于已因积分耗尽而移出队列的旧用户;此时 current_admin_uid 已经是下一位。
# 用 billing_uid 作为本次配置组的真正完成用户,避免误把新队首出队。
finished_uid = self.state.get("billing_uid") or current_admin_uid
promoted_uid = None
finished_user_is_current_admin = bool(
finished_uid and finished_uid == current_admin_uid
)
self.state["current_group"] = None
self.state["group_start_time"] = None
# 只有完成者仍是当前队首时,才清理该队首的操作窗口。
# 若旧用户已因积分耗尽/主动退出提前出队,当前队首的120秒窗口属于新用户,必须保留。
if finished_user_is_current_admin or not current_admin_uid:
self.state["admin_window_end"] = None
if keep_current and finished_user_is_current_admin:
self.state["login_status"] = "logged_in"
self.state["has_user_finished_once"] = True
self._save()
if self.stats_store:
self.stats_store.record_group_run(
current_run_id,
status="completed",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
completed_count=1,
payload={"finished_uid": finished_uid, "kept": True},
)
return {"accepted": True,
"need_default": False,
"finished_uid": finished_uid,
"promoted_uid": None,
"kept": True}
if finished_user_is_current_admin or not current_admin_uid:
self.state["login_status"] = None
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
# 配置组跑完超过3分钟,本次配置组所属用户这次机会用完,必须出队。
# 如果旧用户已因积分耗尽提前出队,current_admin_uid 可能已经是下一位,不能清掉下一位。
if finished_uid and finished_uid in self.state["queue"]:
self.state["queue"].remove(finished_uid)
if finished_uid:
self.state.setdefault("queue_joined_at", {}).pop(str(finished_uid), None)
if self.state.get("current_admin_uid") == finished_uid:
self.state["current_admin_uid"] = None
if self.state["queue"]:
promoted_uid = self.state["queue"][0]
self._set_admin(promoted_uid)
# 当前队首若不是完成用户,说明其此前已被提升并已经提醒过;本次没有新提升,不重复播报。
self.state["has_user_finished_once"] = True
need_default = len(self.state["queue"]) == 0
self._save()
if self.stats_store:
self.stats_store.record_group_run(
current_run_id,
status="completed",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
completed_count=1,
payload={
"finished_uid": finished_uid,
"promoted_uid": promoted_uid,
"kept": False,
"was_default_running": False,
},
)
return {"accepted": True,
"need_default": need_default,
"finished_uid": finished_uid,
"promoted_uid": promoted_uid,
"kept": False,
"was_default_running": False}
def check_admin_window_timeout(self) -> dict:
"""检查队首固定等待“上号”窗口。仅未开始登录时生效。"""
running_blocks_timeout = (
self.state["current_group"] is not None
and not self.state.get("default_running")
and self.state.get("billing_uid") in (None, self.state.get("current_admin_uid"))
)
if (
self.state["admin_window_end"] is None
or running_blocks_timeout
or self.state.get("login_status") is not None
):
return {"timeout": False, "kicked_uid": None}
now = datetime.now().timestamp()
if now < self.state["admin_window_end"]:
return {"timeout": False, "kicked_uid": None}
# 超时,队首过号。若当前仍在运行前一位用户的配置组,必须保留旧任务和旧计费归属;
# 否则第一次过号会清空 billing_uid,下一位会被误判为当前配置组执行者而永久跳过超时检查。
kicked = self.state["current_admin_uid"]
login_session_id = self.state.get("login_session_id")
running_group = self.state.get("current_group")
running_group_run_id = self.state.get("current_group_run_id")
billing_uid = self.state.get("billing_uid")
preserve_previous_run = (
running_group is not None
and billing_uid is not None
and billing_uid != kicked
)
preserved_billing_started_at = self.state.get("billing_started_at")
preserved_billing_last_at = self.state.get("billing_last_at")
self.logger.info(f"[队列] {kicked} 90秒窗口超时, 过号")
if kicked and kicked in self.state["queue"]:
self.state["queue"].remove(kicked)
if kicked:
self.state.setdefault("queue_joined_at", {}).pop(str(kicked), None)
self.state["current_admin_uid"] = None
self.state["login_status"] = None
self.state["login_session_id"] = None
self.state["login_started_at"] = None
self.state["confirm_started_at"] = None
if not preserve_previous_run:
self.state["billing_started_at"] = None
self.state["billing_last_at"] = None
self.state["billing_uid"] = None
self._promote_next()
if preserve_previous_run:
# _set_admin() 会清空计费字段;提升下一位后恢复旧用户计费,使后续队首仍按各自120秒窗口过号。
self.state["current_group_run_id"] = running_group_run_id
self.state["billing_uid"] = billing_uid
self.state["billing_started_at"] = preserved_billing_started_at
self.state["billing_last_at"] = preserved_billing_last_at
self._save()
if self.stats_store and login_session_id:
self.stats_store.record_login_session(
login_session_id,
status="timeout",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
metadata_json=json.dumps({"reason": "admin_window_timeout"}, ensure_ascii=False),
)
if self.stats_store and kicked:
self.stats_store.record_queue_event(
"main",
"timeout",
item_id=str(kicked),
position=1,
queue_size=len(self.state["queue"]),
payload={
"platform": "bilibili",
"platform_user_id": str(kicked),
"reason": "admin_window_timeout",
"promoted_uid": self.state.get("current_admin_uid"),
},
)
return {"timeout": True, "kicked_uid": kicked}
def get_queue_info(self) -> dict:
return {
"queue": list(self.state["queue"]),
"current_admin": self.state["current_admin_uid"],
"current_group": self.state["current_group"],
"admin_window_end": self.state["admin_window_end"],
"default_running": self.state["default_running"],
"login_status": self.state.get("login_status"),
"billing_started_at": self.state.get("billing_started_at"),
"billing_last_at": self.state.get("billing_last_at"),
}
def is_admin(self, uid: int) -> bool:
return uid == self.state["current_admin_uid"]
# ============== TTS 引擎 ==============
_TTS_DIGITS_CN = "零一二三四五六七八九"
def _int_to_cn_for_tts(num: int) -> str:
"""把较短整数转成普通中文读法,用于 TTS 文本预处理。"""
if num < 0:
return "减" + _int_to_cn_for_tts(abs(num))
if num < 10:
return _TTS_DIGITS_CN[num]
if num < 20:
return "十" + (_TTS_DIGITS_CN[num % 10] if num % 10 else "")
if num < 100:
tens, ones = divmod(num, 10)
return _TTS_DIGITS_CN[tens] + "十" + (_TTS_DIGITS_CN[ones] if ones else "")
if num < 1000:
hundreds, rest = divmod(num, 100)
if rest == 0:
return _TTS_DIGITS_CN[hundreds] + "百"
joiner = "零" if rest < 10 else ""
return _TTS_DIGITS_CN[hundreds] + "百" + joiner + _int_to_cn_for_tts(rest)
return str(num)
def normalize_tts_text(text: str) -> str:
"""仅用于语音播报的文本归一化,不影响弹幕原文。
重点处理积分余额等场景里的负数,避免 TTS 把 -1 读成“负一”,改成“减一”。
"""
if not text:
return text
def replace_negative(match: re.Match) -> str:
prefix = match.group(1) or ""
value = int(match.group(2))
return prefix + "减" + _int_to_cn_for_tts(value)
# 只处理前面不是英文、数字或小数点的负整数,避免误改路径/ID/版本号等内容。
return re.sub(r"(^|[^A-Za-z0-9.])-([0-9]+)(?=\D|$)", replace_negative, text)
class _StreamingAudioBuffer:
"""Thread-safe bridge between GPU chunk generation and the audio player."""
END = object()
def __init__(self):
self.chunks: thread_queue.Queue = thread_queue.Queue()
self.ready = threading.Event()
self.sample_rate = 24000
self.error: Exception | None = None
self.has_audio = False
self._finished = False
self._lock = threading.Lock()
def put(self, chunk, sample_rate: int) -> None:
with self._lock:
if self._finished:
return
self.sample_rate = int(sample_rate or 24000)
self.has_audio = True
self.ready.set()
self.chunks.put(chunk)
def finish(self, error: Exception | None = None) -> None:
with self._lock:
if self._finished:
return
self._finished = True
self.error = error
self.ready.set()
self.chunks.put(self.END)
class _BoundedPriorityQueue:
"""Small priority queue that replaces the least valuable pending item when full."""
def __init__(self, maxsize: int):
self.maxsize = max(1, int(maxsize))
self._heap: list[tuple[int, int, dict[str, Any]]] = []
self._condition = asyncio.Condition()
self._closed = False
def qsize(self) -> int:
return len(self._heap)
async def put(self, item: dict[str, Any]) -> tuple[bool, dict[str, Any] | None]:
entry = (int(item["priority"]), int(item["sequence"]), item)
async with self._condition:
if self._closed:
return False, None
dropped = None
if len(self._heap) >= self.maxsize:
worst_index = max(
range(len(self._heap)),
key=lambda index: (self._heap[index][0], self._heap[index][1]),
)
worst = self._heap[worst_index]
if entry[:2] >= worst[:2]:
return False, None
dropped = worst[2]
self._heap[worst_index] = entry
heapq.heapify(self._heap)
else:
heapq.heappush(self._heap, entry)
self._condition.notify()
return True, dropped
async def get(self) -> dict[str, Any] | None:
async with self._condition:
while not self._heap and not self._closed:
await self._condition.wait()
if not self._heap:
return None
return heapq.heappop(self._heap)[2]
async def close(self) -> list[dict[str, Any]]:
async with self._condition:
self._closed = True
pending = [entry[2] for entry in self._heap]
self._heap.clear()
self._condition.notify_all()
return pending
class TTSEngine:
"""TTS 抽象层。根据 config.tts_provider 选择具体引擎。
支持: none / edge-tts / gptsovits / cosyvoice / volcengine / dots-tts / faster-qwen3-tts
子引擎需实现 async def _synthesize(text) -> bytes (wav/mp3 二进制)"""
def __init__(self, config: Config, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.config = config
self.logger = logger
self.stats_store = stats_store
self.provider = config.tts_provider
self.enabled = (self.provider != "none")
self._engine = None
self._player_lock = asyncio.Lock()
self._synthesis_lock = asyncio.Lock()
self._pygame = None
self._state_file = Path(config.data_dir) / "tts_state.json"
self._state = self._load_state()
self._state["enabled"] = self.enabled
self._state["provider"] = self.provider
self._save_state()
if not self.enabled:
return
try:
if self.provider == "edge-tts":
self._engine = EdgeTTSEngine(config, logger)
elif self.provider == "gptsovits":
self._engine = GPTSoVITSEngine(config, logger)
elif self.provider == "cosyvoice":
self._engine = CosyVoiceEngine(config, logger)
elif self.provider == "volcengine":
self._engine = VolcEngineTTS(config, logger)
elif self.provider == "dots-tts":
self._engine = DotsTTSEngine(config, logger, self)
elif self.provider == "faster-qwen3-tts":
self._engine = FasterQwen3TTSEngine(config, logger, self)
else:
self.logger.warning(f"[TTS] 未知provider '{self.provider}', TTS关闭")
self.enabled = False
except Exception as e:
self.logger.error(f"[TTS] 初始化失败({self.provider}): {e}")
self.enabled = False
self._state["enabled"] = self.enabled
self._state["provider"] = self.provider
self._save_state()
def _load_state(self) -> dict:
default = {
"enabled": False,
"provider": "none",
"model_loaded": False,
"model_name": "",
"last_text": "",
"last_duration_ms": 0,
"last_error": "",
"total_synthesized": 0,
"total_errors": 0,
"recent_events": [],
"updated_at": 0,
}
if not self._state_file.exists():
return default
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
if isinstance(data, dict):
default.update(data)
except Exception:
pass
return default
def _save_state(self):
try:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
self._state["updated_at"] = time.time()
tmp = self._state_file.with_suffix(".tmp")
tmp.write_text(json.dumps(self._state, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self._state_file)
except Exception as e:
self.logger.debug(f"[TTS] 保存状态失败: {e}")
def _log_event(self, msg: str):
try:
now = time.strftime("%H:%M:%S", time.localtime())
events = self._state.setdefault("recent_events", [])
events.append({"time": now, "msg": msg})
if len(events) > 20:
events[:] = events[-20:]
self._save_state()
except Exception:
pass
def set_state(self, **kwargs):
self._state.update(kwargs)
self._save_state()
def create_request(self, text: str, *, parent_request_id: str | None = None,
source: str = "broadcast") -> dict[str, Any] | None:
if not self.enabled or not self._engine or not text:
return None
normalized = normalize_tts_text(text)
request = {
"request_id": uuid.uuid4().hex,
"text": normalized,
"started_at": time.time(),
"payload": {
"parent_broadcast_id": parent_request_id,
"source": source,
},
}
if self.stats_store:
self.stats_store.record_tts_request(
request["request_id"],
voice=self.provider,
text_length=len(normalized),
status="queued",
payload=request["payload"],
)
return request
async def warmup(self) -> bool:
if not self.enabled or not self._engine:
return False
warmup = getattr(self._engine, "warmup", None)
if not warmup:
return True
self._log_event("启动预热开始")
try:
async with self._synthesis_lock:
await warmup()
self._log_event("启动预热完成")
return True
except Exception as e:
self.logger.error(f"[TTS] 启动预热失败: {e}")
self._state["last_error"] = str(e)
self._save_state()
return False
async def restart_worker(self, reason: str = "scheduled") -> bool:
"""重建底层引擎的 worker 进程(若引擎支持),消除长运行性能退化。"""
if not self.enabled or not self._engine:
return False
restart = getattr(self._engine, "restart_worker", None)
if not restart:
return False
self._log_event(f"worker 定时重建 ({reason})")
try:
async with self._synthesis_lock:
result = restart(reason=reason)
if asyncio.iscoroutine(result):
result = await result
self._log_event(f"worker 重建完成 ({reason})")
return bool(result)
except Exception as e:
self.logger.error(f"[TTS] worker 重建失败 ({reason}): {e}")
self._state["last_error"] = str(e)
self._save_state()
return False
async def prepare_request(self, request: dict[str, Any]) -> dict[str, Any]:
text = str(request["text"])
self._log_event(f"开始合成: {text[:30]}")
if getattr(self._engine, "streaming", False) and hasattr(self._engine, "_synthesize_to_buffer"):
buffer = _StreamingAudioBuffer()
async def _stream_with_lock():
async with self._synthesis_lock:
await self._engine._synthesize_to_buffer(text, buffer)
synth_task = asyncio.create_task(
_stream_with_lock(),
name=f"tts-stream-{request['request_id'][:8]}",
)
await asyncio.to_thread(buffer.ready.wait)
if buffer.error and not buffer.has_audio:
await asyncio.gather(synth_task, return_exceptions=True)
raise buffer.error
return {"kind": "stream", "buffer": buffer, "synth_task": synth_task}
async with self._synthesis_lock:
audio = await self._engine._synthesize(text)
if not audio:
raise RuntimeError("合成返回空音频")
return {"kind": "audio", "audio": audio}
def mark_playing(self, request: dict[str, Any]) -> None:
if self.stats_store:
self.stats_store.record_tts_request(
request["request_id"],
voice=self.provider,
text_length=len(request["text"]),
status="playing",
payload=request["payload"],
)
async def play_prepared(self, prepared: dict[str, Any]) -> bool:
if prepared["kind"] == "stream":
await self._play_stream_buffer(prepared["buffer"])
await prepared["synth_task"]
return True
return await self._play_audio(prepared["audio"])
def complete_request(self, request: dict[str, Any]) -> None:
duration_ms = int((time.time() - request["started_at"]) * 1000)
text = str(request["text"])
self._state["total_synthesized"] = self._state.get("total_synthesized", 0) + 1
self._state["last_text"] = text
self._state["last_duration_ms"] = duration_ms
self._state["last_error"] = ""
self._save_state()
label = "流式播放完成" if getattr(self._engine, "streaming", False) else "播放完成"
self._log_event(f"{label} ({duration_ms}ms): {text[:30]}")
if self.stats_store:
self.stats_store.record_tts_request(
request["request_id"],
completed_at_utc=datetime.now(timezone.utc).isoformat(),
voice=self.provider,
text_length=len(text),
status="success",
duration_ms=duration_ms,
result_code="ok",
payload=request["payload"],
)
def fail_request(self, request: dict[str, Any], error: Exception | str,
*, result_code: str | None = None, cancelled: bool = False) -> None:
duration_ms = int((time.time() - request["started_at"]) * 1000)
err = str(error)
if "device-side assert" in err:
err = "CUDA device-side assert:当前 Python 进程的 CUDA 上下文已损坏,必须关闭 DanmuQueue 后重新启动;单纯刷新后台无效。原始错误: " + err
if not cancelled:
self._state["total_errors"] = self._state.get("total_errors", 0) + 1
self._state["last_error"] = err
self._save_state()
self._log_event(f"异常: {err[:80]}")
if self.stats_store:
self.stats_store.record_tts_request(
request["request_id"],
completed_at_utc=datetime.now(timezone.utc).isoformat(),
voice=self.provider,
text_length=len(request["text"]),
status="cancelled" if cancelled else "failed",
duration_ms=duration_ms,
result_code=result_code or ("cancelled" if cancelled else type(error).__name__),
payload=request["payload"],
)
async def speak(self, text: str, *, parent_request_id: str | None = None,
source: str = "broadcast") -> bool:
"""Direct compatibility path; Broadcaster normally uses the pipelined API."""
request = self.create_request(text, parent_request_id=parent_request_id, source=source)
if not request:
return False
try:
prepared = await self.prepare_request(request)
async with self._player_lock:
self.mark_playing(request)
await self.play_prepared(prepared)
self.complete_request(request)
return True
except asyncio.CancelledError:
self.fail_request(request, "cancelled", cancelled=True)
raise
except Exception as e:
self.logger.error(f"[TTS] 播放失败: {e}")
self.fail_request(request, e)
return False
async def close(self) -> None:
close = getattr(self._engine, "close", None)
try:
if close:
result = close()
if asyncio.iscoroutine(result):
await result
finally:
try:
if self._pygame is not None and self._pygame.mixer.get_init():
self._pygame.mixer.quit()
except Exception:
pass
self._pygame = None
async def _play_stream_buffer(self, buffer: _StreamingAudioBuffer) -> bool:
def _play() -> None:
import sounddevice as sd
stream = None
try:
while True:
chunk = buffer.chunks.get()
if chunk is _StreamingAudioBuffer.END:
break
if stream is None:
stream = sd.OutputStream(
samplerate=buffer.sample_rate,
channels=1,
dtype="float32",
)
stream.start()
self.logger.info(f"[TTS] 首块到达,开始流式播放 (sr={buffer.sample_rate})")
stream.write(chunk.reshape(-1, 1))
finally:
if stream is not None:
stream.stop()
stream.close()
if buffer.error:
raise buffer.error
if not buffer.has_audio:
raise RuntimeError("流式合成返回空音频")
await asyncio.to_thread(_play)
return True
async def _play_audio(self, audio: bytes) -> bool:
"""播放音频到系统默认输出设备,成功返回 True,失败抛出异常。"""
try:
import io
import pygame
if self._pygame is None:
self._pygame = pygame
if not self._pygame.mixer.get_init():
self._pygame.mixer.init()
channel = self._pygame.mixer.Sound(file=io.BytesIO(audio)).play()
if channel is None:
raise RuntimeError("pygame mixer 无可用播放通道")
while channel.get_busy():
await asyncio.sleep(0.1)
return True
except ImportError:
pass
proc = await asyncio.create_subprocess_exec(
"ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet",
"-i", "pipe:0",
stdin=subprocess.PIPE,
)
proc.stdin.write(audio)
await proc.stdin.drain()
proc.stdin.close()
return_code = await proc.wait()
if return_code != 0:
raise RuntimeError(f"ffplay退出码异常: {return_code}")
return True
class EdgeTTSEngine:
"""免费占位引擎: 微软Edge TTS。不支持自定义音色。"""
def __init__(self, config: Config, logger: logging.Logger):
self.cfg = config.tts_cfg.get("edge-tts", {})
self.voice = self.cfg.get("voice", "zh-CN-XiaoxiaoNeural")
self.rate = self.cfg.get("rate", "+0%")
self.volume = self.cfg.get("volume", "+0%")
self.logger = logger
try:
import edge_tts # type: ignore
self._mod = edge_tts
except ImportError:
raise ImportError("未安装 edge-tts, 请运行: pip install edge-tts")
async def _synthesize(self, text: str) -> bytes:
import io
buf = io.BytesIO()
communicate = self._mod.Communicate(
text, self.voice, rate=self.rate, volume=self.volume
)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
buf.write(chunk["data"])
buf.seek(0)
return buf.read()
class GPTSoVITSEngine:
"""本地 GPT-SoVITS 引擎。需先启动 GPT-SoVITS API 服务(默认9880端口)。"""
def __init__(self, config: Config, logger: logging.Logger):
self.cfg = config.tts_cfg.get("gptsovits", {})
self.api_url = self.cfg.get("api_url", "http://127.0.0.1:9880").rstrip("/")
self.character = self.cfg.get("character", "default")
self.emotion = self.cfg.get("emotion", "happy")
self.logger = logger
async def _synthesize(self, text: str) -> bytes:
url = f"{self.api_url}/tts"
params = {
"text": text,
"character": self.character,
"emotion": self.emotion,
"text_lang": "zh",
}
async with aiohttp.ClientSession() as sess:
async with sess.post(url, json=params, timeout=30) as resp:
if resp.status != 200:
self.logger.error(f"[TTS] GPT-SoVITS HTTP {resp.status}: {await resp.text()}")
return b""
return await resp.read()
class CosyVoiceEngine:
"""阿里云 CosyVoice (DashScope)。需DashScope API key + 预置/克隆音色ID。"""
def __init__(self, config: Config, logger: logging.Logger):
self.cfg = config.tts_cfg.get("cosyvoice", {})
self.api_key = self.cfg.get("api_key", "")
self.voice = self.cfg.get("voice", "longxiaochun")
self.model = self.cfg.get("model", "cosyvoice-v1")
self.logger = logger
if not self.api_key:
raise ValueError("CosyVoice 缺少 api_key")
async def _synthesize(self, text: str) -> bytes:
# DashScope WebSocket TTS 协议
url = f"wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
headers = {
"Authorization": f"bearer {self.api_key}",
"X-DashScope-DataInspection": "enable",
}
payload = {
"model": self.model,
"input": {"text": text},
"parameters": {"voice": self.voice, "format": "mp3"},
}
audio_chunks = []
try:
async with websockets.connect(url, additional_headers=headers) as ws:
await ws.send(json.dumps({
"action": "run-task",
"header": {"event_id": "tts-1", "action": "run-task"},
"payload": payload,
"payload": {"task_group": "audio", "task": "tts", "function": "SpeechSynthesizer", "model": self.model},
"input": {"text": text},
"parameters": {"voice": self.voice, "format": "mp3", "sample_rate": 16000},
}))
async for msg in ws:
data = json.loads(msg)
if data.get("header", {}).get("event") == "task-finished":
break
if data.get("header", {}).get("event") == "result-generated":
audio = data.get("output", {}).get("audio", "")
if audio:
import base64
audio_chunks.append(base64.b64decode(audio))
except Exception as e:
self.logger.error(f"[TTS] CosyVoice 合成失败: {e}")
return b""
return b"".join(audio_chunks)
class VolcEngineTTS:
"""火山引擎声音复刻。需 app_id + access_token + voice_type。"""
def __init__(self, config: Config, logger: logging.Logger):
self.cfg = config.tts_cfg.get("volcengine", {})
self.app_id = self.cfg.get("app_id", "")
self.access_token = self.cfg.get("access_token", "")
self.voice_type = self.cfg.get("voice_type", "BV001_streaming")
self.logger = logger
if not self.app_id or not self.access_token:
raise ValueError("火山TTS缺少 app_id 或 access_token")
async def _synthesize(self, text: str) -> bytes:
# 火山引擎 TTS HTTP API
url = "https://openspeech.bytedance.com/api/v1/tts"
payload = {
"app": {"appid": self.app_id, "token": self.access_token, "cluster": "volcano_tts"},
"user": {"uid": "bgi_live"},
"audio": {
"voice_type": self.voice_type,
"encoding": "mp3",
"speed_ratio": 1.0,
"volume_ratio": 1.0,
"pitch_ratio": 1.0,
},
"request": {
"reqid": str(int(time.time() * 1000)),
"text": text,
"text_type": "plain",
"operation": "query",
},
}
headers = {"Content-Type": "application/json"}
async with aiohttp.ClientSession() as sess:
async with sess.post(url, json=payload, headers=headers, timeout=15) as resp:
result = await resp.json()
if result.get("code") != 3000:
self.logger.error(f"[TTS] 火山引擎错误: {result}")
return b""
import base64
audio = result.get("data", "")
return base64.b64decode(audio) if audio else b""
class FasterQwen3TTSEngine:
"""faster-qwen3-tts 引擎,使用 Base 模型 + 语音克隆。
pip install faster-qwen3-tts
API: https://github.com/andimarafioti/faster-qwen3-tts
模型与 CUDA 上下文运行在独立 worker 进程中。主进程负责超时熔断与自动重启,
避免异常长合成拖慢音乐播放和其他直播业务。
"""
def __init__(self, config: Config, logger: logging.Logger, parent: "TTSEngine | None" = None):
self.cfg = config.tts_cfg.get("faster-qwen3-tts", {})
self.model_name = self.cfg.get("model_name_or_path", "Qwen/Qwen3-TTS-12Hz-0.6B-Base")
self.language = str(self.cfg.get("language", "Chinese"))
self.ref_audio = str(self.cfg.get("ref_audio", "") or "")
self.ref_text = str(self.cfg.get("ref_text", "") or "")
self.xvec_only = bool(self.cfg.get("xvec_only", True))
self.non_streaming_mode = bool(self.cfg.get("non_streaming_mode", True))
self.append_silence = bool(self.cfg.get("append_silence", True))
self.streaming = False
self.device = str(self.cfg.get("device", "cuda"))
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.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal")
self.logger = logger
self._parent = parent
self._worker_ready = False
self._warmed_up = False
model_path = project_path(self.model_name)
resolved_model_name = str(model_path) if model_path.exists() else self.model_name
resolved_ref_audio = str(project_path(self.ref_audio)) if self.ref_audio else ""
self._worker = FasterQwenWorkerClient(
{
"model_name_or_path": resolved_model_name,
"language": self.language,
"ref_audio": resolved_ref_audio,
"ref_text": self.ref_text,
"xvec_only": self.xvec_only,
"non_streaming_mode": self.non_streaming_mode,
"append_silence": self.append_silence,
"device": self.device,
"cpu_threads": self.cpu_threads,
"cpu_affinity_count": self.cpu_affinity_count,
"process_priority": self.process_priority,
},
logger,
synthesis_timeout_seconds=120,
)
if bool(self.cfg.get("streaming", False)):
self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true")
def _update_state(self, **kwargs):
if self._parent:
self._parent.set_state(**kwargs)
async def _ensure_model(self):
if self._worker_ready and self._worker.worker_pid:
return
self._update_state(model_loaded=False, model_name=self.model_name, last_error="")
dev_label = "CPU" if self.device == "cpu" else "GPU"
self.logger.info(f"[FasterQwenTTS] 启动独立 worker: {self.model_name} ({dev_label})")
try:
await asyncio.to_thread(self._worker.ensure_ready)
self._worker_ready = True
self._update_state(model_loaded=True)
clone = "xvec_only" if self.xvec_only else "ICL"
self.logger.info(
f"[FasterQwenTTS] 独立 worker 就绪, pid={self._worker.worker_pid}, "
f"{dev_label} + {clone}, max_new_tokens=384, "
f"cpu_threads={self.cpu_threads}, affinity={self.cpu_affinity_count}, "
f"priority={self.process_priority}"
)
except Exception as e:
self._worker_ready = False
self._update_state(model_loaded=False, last_error=str(e))
raise
async def _precompute_prompt(self):
await self._ensure_model()
async def warmup(self):
if self._warmed_up:
return
self.logger.info("[FasterQwenTTS] 启动独立 worker、加载模型并预热...")
started = time.time()
await self._ensure_model()
self._warmed_up = True
self.logger.info(f"[FasterQwenTTS] 启动预热完成,耗时 {int((time.time() - started) * 1000)} ms")
def _check_ref_audio(self) -> bool:
if not self.ref_audio:
err = "Faster-Qwen3-TTS (Base) 需要 ref_audio 参考音频;请先在 config.json 中配置"
self.logger.warning(f"[FasterQwenTTS] {err}")
self._update_state(last_error=err)
return False
return True
async def _synthesize(self, text: str) -> bytes:
"""非流式合成,返回完整 WAV bytes。"""
await self._ensure_model()
safe_text = text.strip()[:80]
if not safe_text:
safe_text = "欢迎来到直播间。"
self._update_state(last_text=safe_text)
if not self._check_ref_audio():
raise RuntimeError("缺少 ref_audio")
try:
audio_bytes, metadata = await asyncio.to_thread(self._worker.synthesize, safe_text)
except Exception as exc:
self._worker_ready = bool(self._worker.worker_pid)
self._update_state(model_loaded=self._worker_ready, last_error=str(exc))
raise
self._worker_ready = True
self._update_state(model_loaded=True, last_error="")
self.logger.info(
f"[FasterQwenTTS] 完成: {len(safe_text)}字 -> {len(audio_bytes)} bytes, "
f"worker={metadata.get('duration_ms')}ms, max_new_tokens=384"
)
return audio_bytes
async def _synthesize_to_buffer(self, text: str, buffer: _StreamingAudioBuffer) -> None:
try:
import io
import numpy as np
import soundfile as sf
audio = await self._synthesize(text)
samples, sample_rate = await asyncio.to_thread(
sf.read,
io.BytesIO(audio),
dtype="float32",
)
buffer.put(np.asarray(samples, dtype=np.float32).reshape(-1), sample_rate)
buffer.finish()
except Exception as e:
buffer.finish(e)
raise
async def close(self):
await asyncio.to_thread(self._worker.close)
self._worker_ready = False
self._warmed_up = False
self._update_state(model_loaded=False)
async def restart_worker(self, reason: str = "scheduled") -> bool:
"""关闭并重建 worker 进程,消除长时间运行后的合成性能退化。"""
started = time.time()
self.logger.info(f"[FasterQwenTTS] 重建 worker ({reason})...")
await self.close()
await self.warmup()
elapsed = int(time.time() - started)
if self._worker_ready:
self.logger.info(f"[FasterQwenTTS] worker 重建完成 ({reason}),耗时 {elapsed}s")
else:
self.logger.error(f"[FasterQwenTTS] worker 重建失败 ({reason}),耗时 {elapsed}s")
return self._worker_ready
class DotsTTSEngine:
"""dots.tts 本地 GPU 引擎(小红书开源, 2B 全连续自回归 TTS)。
GitHub: https://github.com/rednote-hilab/dots.tts
推荐使用 MF(MeanFlow 蒸馏)模型,4 步推理,RTX 2080 8GB 可用。
配置示例 (config.json):
"dots-tts": {
"model_name_or_path": "rednote-hilab/dots.tts-mf",
"device": "cuda",
"optimize": false,
"num_steps": 4,
"language": "chinese"
}
"""
def __init__(self, config: Config, logger: logging.Logger, parent: "TTSEngine | None" = None):
self.cfg = config.tts_cfg.get("dots-tts", {})
self.model_name = self.cfg.get("model_name_or_path", "rednote-hilab/dots.tts-mf")
self.device = self.cfg.get("device", "auto") # dots.tts 当前运行时会自动选择 cuda/cpu
self.precision = str(self.cfg.get("precision", "float16")) # RTX 2080 不支持 bfloat16;优先使用 float16,源码补丁处理 Float/Half 混用
self.optimize = bool(self.cfg.get("optimize", False))
self.max_generate_length = int(self.cfg.get("max_generate_length", 500))
self.num_steps = int(self.cfg.get("num_steps", 4))
self.language = str(self.cfg.get("language", "chinese"))
self.logger = logger
self._parent = parent
self._model = None # 懒加载, 首次合成时初始化
def _update_state(self, **kwargs):
if self._parent:
self._parent.set_state(**kwargs)
async def _ensure_model(self):
if self._model is not None:
return
self._update_state(model_loaded=False, model_name=self.model_name, last_error="")
self.logger.info(f"[DotsTTS] 正在加载模型: {self.model_name}")
self._update_state(last_text=f"[系统] 正在加载模型 {self.model_name}...")
try:
from dots_tts.runtime import DotsTtsRuntime
def _load(precision, optimize):
return DotsTtsRuntime.from_pretrained(
self.model_name,
precision=precision,
optimize=optimize,
max_generate_length=self.max_generate_length,
)
# 模型加载是重度同步操作,放到线程池避免阻塞事件循环
self._model = await asyncio.to_thread(_load, self.precision, self.optimize)
self._update_state(model_loaded=True, model_name=self.model_name, last_error="")
runtime_device = getattr(self._model, "device", "auto")
self.logger.info(f"[DotsTTS] 模型加载完成, device={runtime_device}, precision={self.precision}")
except ImportError:
raise ImportError(
"dots.tts 未安装。安装方式:\n"
" pip install -e /path/to/dots.tts"
)
except Exception as e:
msg = str(e).lower()
if "out of memory" in msg or "cuda" in msg:
self.logger.warning(f"[DotsTTS] GPU 显存不足({e}), 回退到 CPU")
self._model = await asyncio.to_thread(_load, "float32", False)
self._update_state(model_loaded=True, model_name=self.model_name, last_error="")
else:
self._update_state(model_loaded=False, last_error=str(e))
raise
async def _synthesize(self, text: str) -> bytes:
await self._ensure_model()
self._update_state(last_text=text)
# generate() 是重度同步 CPU/GPU 操作,放到线程池避免阻塞事件循环
def _do_generate():
return self._model.generate(
text=text,
prompt_audio_path=None,
prompt_text=None,
language=self.language,
template_name="tts",
num_steps=self.num_steps,
)
result = await asyncio.to_thread(_do_generate)
def _extract_audio():
import torch
audio = result["audio"].float().cpu().squeeze().numpy()
sample_rate = result["sample_rate"]
import io
import soundfile as sf
buf = io.BytesIO()
sf.write(buf, audio, sample_rate, format="WAV")
buf.seek(0)
return buf.read()
audio_bytes = await asyncio.to_thread(_extract_audio)
self.logger.info(f"[DotsTTS] 合成完成: {len(text)}字 -> {len(audio_bytes)} bytes")
return audio_bytes
# ============== 直播播报器 ==============
class Broadcaster:
"""统一播报: B站发弹幕 + TTS语音。
- 弹幕发送走队列, 间隔 danmu_interval_sec 秒(B站风控约5秒1条)。
- TTS 使用有界优先队列,合成与播放分别由单一 worker 串行处理。
- broadcast(text, tts=True) 同时发弹幕+TTS。"""
def __init__(self, config: Config, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.config = config
self.logger = logger
self.stats_store = stats_store
self.room_id = config.room_id
self.sessdata = config.sessdata
self.bili_jct = config.bili_jct
self.enable_danmu = (config.enable_danmu_reply or config.broadcast_cfg.get("enable_system_danmu", True)) and bool(self.bili_jct)
self.enable_tts = config.enable_tts
self.danmu_interval = config.danmu_interval_sec
self._danmu_queue: asyncio.Queue = asyncio.Queue()
tts_queue_cfg = config.broadcast_cfg.get("tts_queue", {})
self._tts_queue_cfg = tts_queue_cfg
self._tts_pending = _BoundedPriorityQueue(int(tts_queue_cfg.get("max_pending", 8)))
self._tts_playback_queue: asyncio.Queue = asyncio.Queue(
maxsize=max(1, int(tts_queue_cfg.get("playback_max_pending", 2)))
)
self._tts_worker_tasks: set[asyncio.Task] = set()
self._tts_sequence = 0
self._tts_started = False
self._tts_warmup_done = asyncio.Event()
self._broadcast_channels: dict[str, dict[str, str]] = {}
self._stop = False
# 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送
self._login_ok = False
# TTS 引擎
self.tts = TTSEngine(config, logger, stats_store=stats_store)
if self.enable_danmu:
self.logger.info(f"[播报] 弹幕发送已启用 (间隔{self.danmu_interval}秒)")
elif (config.enable_danmu_reply or config.broadcast_cfg.get("enable_system_danmu", True)) and not self.bili_jct:
self.logger.warning("[播报] 弹幕发送未启用: 缺少 bili_jct。需要在 config.json 的 bilibili.bili_jct 填入浏览器 Cookie 里的 bili_jct;仅有 SESSDATA 不能调用发弹幕接口")
if self.tts.enabled:
self.logger.info(f"[播报] TTS已启用 ({self.tts.provider})")
else:
self.logger.info("[播报] TTS未启用")
def reload_config(self):
old_tts = self.tts
old_tts_provider = getattr(old_tts, "provider", "none") if old_tts else "none"
old_tts_enabled = bool(getattr(old_tts, "enabled", False)) if old_tts else False
self.room_id = self.config.room_id
self.sessdata = self.config.sessdata
self.bili_jct = self.config.bili_jct
self.enable_danmu = (self.config.enable_danmu_reply or self.config.broadcast_cfg.get("enable_system_danmu", True)) and bool(self.bili_jct)
self._login_ok = False
self.enable_tts = self.config.enable_tts
self.danmu_interval = self.config.danmu_interval_sec
if old_tts_provider != self.config.tts_provider or old_tts_enabled != self.enable_tts:
self.tts = TTSEngine(
self.config,
self.logger,
stats_store=self.stats_store,
)
if old_tts:
try:
asyncio.get_running_loop().create_task(
old_tts.close(),
name="关闭旧TTS引擎",
)
except RuntimeError:
self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭")
self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}")
async def start(self):
if self._tts_started:
await self._tts_warmup_done.wait()
return
self._tts_started = True
if not self.tts.enabled:
self._tts_warmup_done.set()
return
synth_task = asyncio.create_task(self._tts_synthesis_loop(), name="TTS合成流水线")
play_task = asyncio.create_task(self._tts_playback_loop(), name="TTS播放流水线")
self._tts_worker_tasks.update((synth_task, play_task))
for task in (synth_task, play_task):
task.add_done_callback(self._tts_worker_tasks.discard)
try:
if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
await self.tts.warmup()
finally:
self._tts_warmup_done.set()
self.logger.info(
f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, "
f"playback={self._tts_playback_queue.maxsize}"
)
def _tts_policy(self, category: str | None) -> tuple[int, float]:
category = str(category or "default")
if category in {"login", "reset"}:
return 0, float(self._tts_queue_cfg.get("urgent_max_age_sec", 60.0))
if category in {"execution", "system"}:
return 1, float(self._tts_queue_cfg.get("max_age_sec", 40.0))
if category in {"queue", "gift", "default"}:
return 2, float(self._tts_queue_cfg.get("max_age_sec", 40.0))
return 3, float(self._tts_queue_cfg.get("low_priority_max_age_sec", 30.0))
def _finish_tts_job(self, job: dict[str, Any], success: bool, result_code: str) -> None:
self._complete_broadcast_channel(
str(job.get("broadcast_request_id") or ""),
"tts",
"success" if success else "failed",
result_code,
)
def _drop_tts_job(self, job: dict[str, Any], result_code: str) -> None:
request = job.get("tts_request")
if request:
self.tts.fail_request(request, result_code, result_code=result_code, cancelled=True)
self.logger.info(
f"[TTS队列] 丢弃 {result_code}: priority={job.get('priority')} "
f"category={job.get('category')} text={str(job.get('text') or '')[:30]}"
)
self._finish_tts_job(job, False, result_code)
async def _tts_synthesis_loop(self):
await self._tts_warmup_done.wait()
self.logger.info("[TTS队列] 合成 worker 启动")
while not self._stop:
job = await self._tts_pending.get()
if job is None:
break
request = job["tts_request"]
if time.monotonic() >= float(job["expires_at"]):
self._drop_tts_job(job, "expired_before_synthesis")
continue
prepared = None
try:
prepared = await self.tts.prepare_request(request)
await self._tts_playback_queue.put({"job": job, "prepared": prepared})
if prepared["kind"] == "stream":
try:
await prepared["synth_task"]
except Exception as e:
self.logger.warning(f"[TTS队列] 流式生成异常,交由播放 worker 收口: {e}")
except asyncio.CancelledError:
if prepared is None:
self.tts.fail_request(request, "cancelled", cancelled=True)
self._finish_tts_job(job, False, "cancelled")
raise
except Exception as e:
self.logger.error(f"[TTS] 合成失败: {e}")
self.tts.fail_request(request, e, result_code=type(e).__name__)
self._finish_tts_job(job, False, "tts_synthesis_failed")
async def _tts_playback_loop(self):
await self._tts_warmup_done.wait()
self.logger.info("[TTS队列] 播放 worker 启动")
while not self._stop:
item = await self._tts_playback_queue.get()
if item is None:
break
job = item["job"]
request = job["tts_request"]
try:
if time.monotonic() >= float(job["expires_at"]):
self._drop_tts_job(job, "expired_before_playback")
continue
self.tts.mark_playing(request)
await self.tts.play_prepared(item["prepared"])
self.tts.complete_request(request)
self._finish_tts_job(job, True, "ok")
except asyncio.CancelledError:
self.tts.fail_request(request, "cancelled", cancelled=True)
self._finish_tts_job(job, False, "cancelled")
raise
except Exception as e:
self.logger.error(f"[TTS] 播放失败: {e}")
self.tts.fail_request(request, e, result_code=type(e).__name__)
self._finish_tts_job(job, False, "tts_playback_failed")
async def _check_login(self):
"""启动时ping nav API, SESSDATA失效时关闭弹幕发送避免刷 -101 错误"""
if not self.enable_danmu:
return
try:
req = urllib.request.Request("https://api.bilibili.com/x/web-interface/nav")
req.add_header("User-Agent", "Mozilla/5.0")
req.add_header("Cookie", f"SESSDATA={self.sessdata}; bili_jct={self.bili_jct}")
data = json.loads(urllib.request.urlopen(req, timeout=5).read())
if data.get("code") == 0:
self._login_ok = True
self.logger.info(f"[播报] SESSDATA有效, 账号={data['data'].get('uname')}, 可发弹幕")
else:
self._login_ok = False
self.enable_danmu = False
self.logger.warning(
f"[播报] SESSDATA失效 (code={data.get('code')}), 关闭弹幕发送, "
f"仅保留TTS播报。请重新从浏览器获取SESSDATA和bili_jct填入config.json"
)
except Exception as e:
self.logger.warning(f"[播报] 检测登录状态失败: {e}")
async def run(self):
"""后台消费弹幕队列。"""
if not self._tts_started:
await self.start()
self.logger.info("[播报] 弹幕发送循环启动")
while not self._stop:
try:
if not self.enable_danmu:
await asyncio.sleep(2)
continue
if not self._login_ok:
await self._check_login()
if not self.enable_danmu:
continue
item = await self._danmu_queue.get()
if item is None:
break
result = await self._send_danmu(str(item.get("text") or ""))
self._complete_broadcast_channel(
str(item.get("request_id") or ""),
"danmu",
"success" if result.get("success") else "failed",
str(result.get("code") or "unknown"),
)
await asyncio.sleep(self.danmu_interval)
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"[播报] 弹幕发送异常: {e}")
await asyncio.sleep(self.danmu_interval)
def stop(self):
self._stop = True
async def close(self):
self.stop()
while not self._danmu_queue.empty():
try:
item = self._danmu_queue.get_nowait()
except asyncio.QueueEmpty:
break
if isinstance(item, dict):
self._complete_broadcast_channel(
str(item.get("request_id") or ""),
"danmu",
"failed",
"cancelled",
)
pending_tts = await self._tts_pending.close()
for job in pending_tts:
self._drop_tts_job(job, "shutdown")
tasks = list(self._tts_worker_tasks)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._tts_worker_tasks.clear()
while not self._tts_playback_queue.empty():
try:
queued = self._tts_playback_queue.get_nowait()
except asyncio.QueueEmpty:
break
if isinstance(queued, dict) and isinstance(queued.get("job"), dict):
self._drop_tts_job(queued["job"], "shutdown")
await self.tts.close()
for request_id, channels in list(self._broadcast_channels.items()):
for channel, status in list(channels.items()):
if status == "pending":
channels[channel] = "failed"
self._complete_broadcast_channel(
request_id,
next(iter(channels), "shutdown"),
channels.get(next(iter(channels), ""), "failed"),
"cancelled",
)
def _complete_broadcast_channel(self, request_id: str, channel: str,
status: str, result_code: str = ""):
if not request_id or request_id not in self._broadcast_channels:
return
channels = self._broadcast_channels[request_id]
channels[channel] = status
if any(value == "pending" for value in channels.values()):
return
final_status = "success" if all(value == "success" for value in channels.values()) else "failed"
if self.stats_store:
self.stats_store.record_broadcast_request(
request_id,
completed_at_utc=datetime.now(timezone.utc).isoformat(),
status=final_status,
result_code=result_code or final_status,
payload={"channel_results": channels},
)
self._broadcast_channels.pop(request_id, None)
async def broadcast(self, text: str, tts: bool = True, danmu: bool = True,
tts_category: str | None = None, tts_priority: int | None = None):
"""统一播报并汇总弹幕、TTS 子通道结果。"""
if not text:
return None
request_id = uuid.uuid4().hex
use_danmu = bool(danmu and self.enable_danmu)
use_tts = bool(tts and self.tts.enabled)
channels = {
name: "pending"
for name, enabled in (("danmu", use_danmu), ("tts", use_tts))
if enabled
}
self._broadcast_channels[request_id] = channels
channel_name = "+".join(channels) if channels else "none"
if self.stats_store:
self.stats_store.record_broadcast_request(
request_id,
channel=channel_name,
content_length=len(text),
status="queued" if channels else "failed",
result_code=None if channels else "no_enabled_channel",
completed_at_utc=None if channels else datetime.now(timezone.utc).isoformat(),
payload={"source": "broadcast", "tts_category": tts_category},
)
self.logger.info(f"[播报] {text}")
if use_danmu:
await self._danmu_queue.put({"request_id": request_id, "text": text})
if use_tts:
if not self._tts_started:
await self.start()
priority, max_age = self._tts_policy(tts_category)
if tts_priority is not None:
priority = int(tts_priority)
tts_request = self.tts.create_request(
text,
parent_request_id=request_id,
source="broadcast",
)
if not tts_request:
self._complete_broadcast_channel(request_id, "tts", "failed", "tts_unavailable")
else:
self._tts_sequence += 1
job = {
"broadcast_request_id": request_id,
"tts_request": tts_request,
"text": text,
"category": tts_category or "default",
"priority": priority,
"sequence": self._tts_sequence,
"expires_at": time.monotonic() + max(1.0, max_age),
}
accepted, dropped = await self._tts_pending.put(job)
if dropped:
self._drop_tts_job(dropped, "queue_replaced")
if not accepted:
self._drop_tts_job(job, "queue_full")
else:
self.logger.debug(
f"[TTS队列] 入队 priority={priority} category={job['category']} "
f"pending={self._tts_pending.qsize()}"
)
if not channels:
self._broadcast_channels.pop(request_id, None)
return request_id
async def _send_danmu(self, text: str) -> dict:
"""调用B站发弹幕API。
POST https://api.live.bilibili.com/msg/send
必填: msg, roomid, rnd, color, mode, fontsize, bubble, csrf, csrf_token
Cookie: SESSDATA + bili_jct
B站弹幕限制30个汉字, 超出裁切并加省略号(保留关键信息)"""
if not text:
return {"success": False, "code": "empty_text"}
# 弹幕长度限制: B站限30个汉字。按字符数裁切, 中文/emoji/全角按1算, 半角按1算(简单len即可)
MAX_LEN = 30
if len(text) > MAX_LEN:
text = text[:MAX_LEN - 1] + "…"
self.logger.info(f"[播报] 弹幕过长, 已裁切: {text}")
# 同步网络请求放到线程池,避免 B 站发弹幕接口慢时(最长 10s)阻塞事件循环。
try:
return await asyncio.to_thread(self._send_danmu_blocking, text)
except Exception as e:
self.logger.error(f"[播报] 发弹幕异常: {e}")
return {"success": False, "code": type(e).__name__}
def _send_danmu_blocking(self, text: str) -> dict:
url = "https://api.live.bilibili.com/msg/send"
rnd = str(int(time.time()))
data = urllib.parse.urlencode({
"bubble": "0",
"msg": text,
"color": "16777215",
"mode": "1",
"fontsize": "25",
"rnd": rnd,
"roomid": str(self.room_id),
"csrf": self.bili_jct,
"csrf_token": self.bili_jct,
}).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.add_header("Cookie", f"SESSDATA={self.sessdata}; bili_jct={self.bili_jct}")
req.add_header("Origin", "https://live.bilibili.com")
req.add_header("Referer", f"https://live.bilibili.com/{self.room_id}")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
if result.get("code") == 0:
self.logger.debug(f"[播报] 已发送弹幕: {text[:30]}")
return {"success": True, "code": "ok"}
code = str(result.get("code") or "api_error")
self.logger.warning(f"[播报] 发弹幕失败: {result.get('message', result)}")
return {"success": False, "code": code}
# ============== BetterGI 日志监控 ==============
class BgiLogMonitor:
"""实时监控BetterGI日志,检测配置组完成。"""
# 完成关键字: 配置组 "组名" 执行结束
FINISH_PATTERN = "执行结束"
LOG_HEADER_PATTERN = re.compile(
r"^\[(?P<time>\d{2}:\d{2}:\d{2})(?:\.\d+)?\]"
r"\s+\[[A-Z]+\](?:\s+\[[^\]\r\n]+\])*\s+.+$"
)
def __init__(self, work_dir: str, logger: logging.Logger):
self.log_dir = Path(work_dir) / "log"
self.logger = logger
self._stop = False
self._on_finish_callback = None
self._current_group = None
self._current_run_id = None
self._position = 0
def set_work_dir(self, work_dir: str):
new_log_dir = Path(work_dir) / "log"
if new_log_dir != self.log_dir:
self.log_dir = new_log_dir
self._position = self._get_current_log_size()
self.logger.info(f"[配置] BetterGI日志目录已更新: {self.log_dir}")
def set_finish_callback(self, callback):
self._on_finish_callback = callback
def set_current_group(self, group_name: str | None, run_id: str | None = None):
"""设置当前配置组及任务实例编号,用于精确匹配完成事件。"""
self._current_group = group_name
self._current_run_id = str(run_id) if group_name and run_id else None
# 重置读取位置到文件末尾(只监听新日志)
self._position = self._get_current_log_size()
def _get_log_path(self) -> Path:
today = datetime.now().strftime("%Y%m%d")
return self.log_dir / f"better-genshin-impact{today}.log"
def _get_current_log_size(self) -> int:
log_path = self._get_log_path()
if log_path.exists():
return log_path.stat().st_size
return 0
@classmethod
def format_display_lines(cls, raw_lines) -> list[str]:
"""把 BetterGI 的“头部行 + 正文行”格式压缩成前端可读的一条日志。"""
formatted: list[str] = []
current_time: str | None = None
current_body: list[str] = []
def flush_current() -> None:
nonlocal current_time, current_body
if current_time and current_body:
formatted.append(f"[{current_time}] " + "\n".join(current_body))
current_time = None
current_body = []
for raw_line in raw_lines:
line = str(raw_line or "").strip()
if not line:
continue
header = cls.LOG_HEADER_PATTERN.match(line)
if header:
flush_current()
current_time = header.group("time")
continue
if current_time:
current_body.append(line)
else:
formatted.append(line)
flush_current()
return formatted
async def run(self):
"""后台监控循环"""
self.logger.info("[日志监控] 启动")
while not self._stop:
try:
await self._check_new_lines()
except Exception as e:
self.logger.debug(f"[日志监控] 异常: {e}")
await asyncio.sleep(2) # 每2秒检查一次
async def _check_new_lines(self):
log_path = self._get_log_path()
if not log_path.exists():
return
try:
size = log_path.stat().st_size
if size < self._position:
# 日志被截断/轮转,重置
self._position = 0
if size == self._position:
return
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(self._position)
new_lines = f.readlines()
self._position = f.tell()
for line in new_lines:
self._process_line(line.strip())
except Exception as e:
self.logger.debug(f"[日志监控] 读文件异常: {e}")
def _process_line(self, line: str):
if not line or not self._current_group:
return
# 匹配: 配置组 "组名" 执行结束
if (self.FINISH_PATTERN in line
and self._current_group in line
and "配置组" in line):
self.logger.info(
f"[日志监控] 检测到配置组 '{self._current_group}' 执行结束"
)
finished = self._current_group
finished_run_id = self._current_run_id
self._current_group = None
self._current_run_id = None
if self._on_finish_callback:
asyncio.create_task(self._on_finish_callback(finished, finished_run_id))
def stop(self):
self._stop = True
# ============== BetterGI 运行器 ==============
class BetterGIRunner:
"""封装 BetterGI.exe 调用 + 进程管理。"""
def __init__(self, exe_path: str, work_dir: str, logger: logging.Logger):
self.exe_path = exe_path
self.work_dir = work_dir
self.logger = logger
# 所有通过本运行器发起的主动停止都会设置短暂保护期。
# watchdog 在保护期内不得把 BetterGI 消失误判为配置组异常结束。
self._intentional_stop_until = 0.0
self._intentional_stop_reason = ""
def update_config(self, exe_path: str, work_dir: str):
changed = exe_path != self.exe_path or work_dir != self.work_dir
self.exe_path = exe_path
self.work_dir = work_dir
if changed:
self.logger.info(f"[配置] BetterGI路径已更新: exe={self.exe_path}, work_dir={self.work_dir}")
def sync_js_script(self, script_name: str) -> bool:
"""将项目内维护的 JS 脚本同步到 BetterGI 实际运行目录。"""
src = INTEGRATIONS_DIR / script_name
if not src.exists():
src = PROJECT_ROOT / script_name
dst = Path(self.work_dir) / "User" / "JsScript" / script_name
if not src.exists():
self.logger.warning(f"[BGI] JS脚本源目录不存在: {src}")
return False
try:
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
target = dst / item.name
if item.is_dir():
if target.exists():
shutil.rmtree(target)
shutil.copytree(item, target)
else:
shutil.copy2(item, target)
self.logger.info(f"[BGI] 已同步JS脚本: {src} -> {dst}")
return True
except Exception as e:
self.logger.error(f"[BGI] 同步JS脚本失败: {e}")
return False
async def start_groups(self, groups: list) -> bool:
"""启动配置组(不阻塞,启动后立即返回)"""
if "扫码上号" in groups:
self.sync_js_script("扫码上号")
cmd = [self.exe_path, "--startGroups"] + groups
self.logger.info(f"[BGI] 调用: {' '.join(cmd)}")
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=self.work_dir,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
# 不等待完成,立即返回(BGI是GUI程序,会持续运行)
await asyncio.sleep(2) # 给2秒启动时间
self.logger.info(f"[BGI] 启动成功: {groups}")
return True
except Exception as e:
self.logger.error(f"[BGI] 启动失败: {e}")
return False
async def start_bgi(self) -> bool:
"""仅启动 BetterGI 主程序,不执行配置组。"""
self.logger.info(f"[BGI] 启动主程序: {self.exe_path}")
try:
await asyncio.create_subprocess_exec(
self.exe_path,
cwd=self.work_dir,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.sleep(2)
return True
except Exception as e:
self.logger.error(f"[BGI] 启动主程序失败: {e}")
return False
async def kill_bgi(self, reason: str = "主动停止", watchdog_grace_sec: float = 15.0):
"""强杀 BetterGI,并为 watchdog 设置主动停止保护期。
保护期从 taskkill 前开始,覆盖进程退出到调用方清理/切换 current_group 的窗口。
即使调用方稍后才保存队列状态,watchdog 也不会重复执行异常结束收尾。
"""
grace_sec = max(5.0, float(watchdog_grace_sec or 15.0))
self._intentional_stop_until = max(
self._intentional_stop_until,
time.monotonic() + grace_sec,
)
self._intentional_stop_reason = str(reason or "主动停止")
self.logger.info(
f"[BGI] taskkill /F /IM BetterGI.exe (主动停止:{self._intentional_stop_reason}, "
f"watchdog保护{grace_sec:g}秒)"
)
try:
proc = await asyncio.create_subprocess_exec(
"taskkill", "/F", "/IM", "BetterGI.exe",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
creationflags=0x08000000, # CREATE_NO_WINDOW
)
await proc.wait()
except Exception as e:
self.logger.debug(f"[BGI] taskkill: {e}")
await asyncio.sleep(2)
def is_intentional_stop_active(self) -> bool:
"""当前是否处于主动停止保护期。"""
return time.monotonic() < self._intentional_stop_until
def intentional_stop_reason(self) -> str:
"""返回当前/最近一次主动停止原因,供日志说明。"""
return self._intentional_stop_reason or "主动停止"
def is_bgi_running(self) -> bool:
"""检查BetterGI是否在运行"""
try:
result = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq BetterGI.exe"],
capture_output=True, text=True, timeout=5,
creationflags=0x08000000,
)
return "BetterGI.exe" in result.stdout
except Exception:
return False
# ============== 扫码登录状态监控 ==============
class LoginMonitor:
"""监听扫码上号 JS 写出的 status.txt,状态从"登录中"变"已登录"后回调。
status.txt 路径: <BetterGI work_dir>/User/JsScript/扫码上号/status.txt
支持的状态值(容错匹配,忽略首尾空白):
登录中 / 已登录 / 失败 / 超时
"""
POLL_INTERVAL = 1.0 # 秒
START_DELAY = 15.0 # 扫码上号 JS 启动后至少等15秒再读取,避免读到上次遗留状态
def __init__(self, bettergi_work_dir: str, logger: logging.Logger):
self.status_path = Path(bettergi_work_dir) / "User" / "JsScript" / "扫码上号" / "status.txt"
self.logger = logger
self._stop = False
self._on_logged_in = None
self._on_login_failed = None
self._last_status = None
self._active_after = 0.0
self._enabled = False
def set_work_dir(self, bettergi_work_dir: str):
new_status_path = Path(bettergi_work_dir) / "User" / "JsScript" / "扫码上号" / "status.txt"
if new_status_path != self.status_path:
self.status_path = new_status_path
self._last_status = None
self.logger.info(f"[配置] 扫码状态文件已更新: {self.status_path}")
def set_logged_in_callback(self, callback):
"""callback: async def callback()"""
self._on_logged_in = callback
def set_login_failed_callback(self, callback):
"""callback: async def callback(status: str)"""
self._on_login_failed = callback
def reset(self):
"""重置监控状态,并延迟读取,避免读到上次 JS 遗留的"已登录"。"""
self._last_status = None
try:
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self.status_path.write_text("", encoding="utf-8")
self.logger.info(f"[登录监控] 已清空旧状态文件: {self.status_path}")
except Exception as e:
self.logger.warning(f"[登录监控] 清空旧状态文件失败: {e}")
self._active_after = time.time() + self.START_DELAY
self._enabled = True
self.logger.info(f"[登录监控] 已重置,{self.START_DELAY:.0f}秒后开始读取 status.txt")
def disable(self):
"""禁用登录监控,避免项目刚启动时读取上次残留的已登录状态。"""
self._enabled = False
self._last_status = None
self._active_after = 0.0
def clear_status_file(self):
"""清空 status.txt,避免下一轮扫码读到上一轮的已登录/失败状态。"""
self._last_status = None
try:
self.status_path.parent.mkdir(parents=True, exist_ok=True)
self.status_path.write_text("", encoding="utf-8")
self.logger.info(f"[登录监控] 已清空状态文件: {self.status_path}")
except Exception as e:
self.logger.warning(f"[登录监控] 清空状态文件失败: {e}")
async def run(self):
self.logger.info(f"[登录监控] 监听: {self.status_path}")
while not self._stop:
try:
if not self._enabled:
await asyncio.sleep(self.POLL_INTERVAL)
continue
if self._active_after and time.time() < self._active_after:
await asyncio.sleep(self.POLL_INTERVAL)
continue
if self.status_path.exists():
raw = self.status_path.read_text(encoding="utf-8").strip()
if raw and raw != self._last_status:
self.logger.info(f"[登录监控] status.txt: {raw}")
self._last_status = raw
if raw and ("已登录" in raw):
if self._on_logged_in:
self._enabled = False
asyncio.create_task(self._on_logged_in())
elif raw and ("失败" in raw or "超时" in raw):
self.logger.warning(f"[登录监控] 扫码状态异常: {raw}")
if self._on_login_failed:
self._enabled = False
asyncio.create_task(self._on_login_failed(raw))
except Exception as e:
self.logger.debug(f"[登录监控] 读取异常: {e}")
await asyncio.sleep(self.POLL_INTERVAL)
def stop(self):
self._stop = True
class SongRequestManager:
"""观众点歌队列:搜索网易云第一个结果,扣积分后加入待播列表。"""
def __init__(self, config: Config, data_dir: str, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.config = config
self.logger = logger
self.stats_store = stats_store
self.data_dir = Path(data_dir)
self.path = self.data_dir / "song_requests.json"
self.state = {
"queue": [],
"active": None,
"history": [],
"played_ids": [],
"played_at": [],
"banned_song_ids": [],
"banned_user_ids": [],
"background_playlist_id": "",
"background_playlist_name": "",
"background_playlist_index": 0,
}
self._current_song_id = None
self._active_song = None
self._last_observed_title = ""
self._last_playing = False
self._last_open_fail_log = 0
self._cdp_launch_attempted = False
self._play_lock = asyncio.Lock()
self._reload_failures = 0
self._player_snapshot = {}
self._background_song = None
self._background_tracks: list[dict[str, Any]] = []
self._background_loaded_at = 0.0
self._background_retry_at = 0.0
self.mpv = MpvPlayer(
project_path(self.cfg.get("mpv_exe", "vendor/mpv/mpv.exe")),
logger,
log_path=project_path("logs/mpv.log"),
)
self.resolver = NeteaseResolver(
logger,
api_base=str(self.cfg.get("api_base", "https://music.163.com")),
music_u=str(
os.environ.get("NETEASE_MUSIC_U")
or self.cfg.get("netease_music_u", "")
or ""
),
)
self._load()
if self.cfg.get("clear_on_start", True):
stale_queue = list(self.state.get("queue", []))
stale_active = dict(self.state.get("active") or {})
old_count = len(stale_queue)
had_active = bool(stale_active)
self._cancel_queued_items(stale_queue)
if stale_active:
self._record_song_request(stale_active, "cancelled")
self._record_playback(stale_active, "cancelled", stop_reason="startup_reset")
self.state["queue"] = []
self._clear_active_song()
self._save()
if old_count or had_active:
self.logger.info(f"[点歌] 启动时已清空上次遗留状态: 队列{old_count}首, 活动任务={'有' if had_active else '无'}")
else:
self._recover_stale_active(reason="process_restart")
@property
def cfg(self) -> dict:
return self.config.music_monitor_cfg.get("request_player", {})
def _load(self):
if self.path.exists():
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(data, dict):
self.state.update(data)
except Exception:
pass
self.state.setdefault("queue", [])
self.state.setdefault("active", None)
self.state.setdefault("history", [])
self.state.setdefault("played_ids", [])
self.state.setdefault("played_at", [])
self.state.setdefault("banned_song_ids", [])
self.state.setdefault("banned_user_ids", [])
self.state.setdefault("background_playlist_id", "")
self.state.setdefault("background_playlist_name", "")
self.state.setdefault("background_playlist_index", 0)
if isinstance(self.state.get("active"), dict):
self._active_song = dict(self.state["active"])
self._current_song_id = str(self._active_song.get("id", "") or "")
def _save(self):
self.data_dir.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.state, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)
def snapshot(self) -> dict:
return {
"queue": list(self.state.get("queue", [])),
"active": dict(self.state.get("active") or {}) if self.state.get("active") else None,
"history": list(self.state.get("history", [])),
"played_ids": list(self.state.get("played_ids", [])),
"banned_song_ids": list(self.state.get("banned_song_ids", [])),
"banned_user_ids": list(self.state.get("banned_user_ids", [])),
"background_playlist": {
"enabled": bool(self.cfg.get("background_playlist_enabled", False)),
"id": self.state.get("background_playlist_id", ""),
"name": self.state.get("background_playlist_name", ""),
"index": int(self.state.get("background_playlist_index", 0) or 0),
"track_count": len(self._background_tracks),
"current": dict(self._background_song) if self._background_song else None,
},
"player": dict(self._player_snapshot),
}
def apply_config(self, config: Config):
self.config = config
self.resolver.update_api_base(str(self.cfg.get("api_base", "https://music.163.com")))
self.resolver.update_auth(
str(os.environ.get("NETEASE_MUSIC_U") or self.cfg.get("netease_music_u", "") or "")
)
self._background_loaded_at = 0.0
self.mpv.update_exe_path(project_path(self.cfg.get("mpv_exe", "vendor/mpv/mpv.exe")))
async def close(self):
await self.mpv.close()
def current_player_state(self) -> dict:
return dict(self._player_snapshot)
def _set_active_song(self, song: dict, *, source: str = ""):
item = dict(song)
item["started_at"] = time.time()
if source and not item.get("source"):
item["source"] = source
self._active_song = item
self._current_song_id = str(item.get("id", "") or "")
self.state["active"] = item
def _clear_active_song(self):
self._active_song = None
self._current_song_id = None
self.state["active"] = None
def _recover_stale_active(self, *, reason: str, played_ms: int = 0) -> bool:
"""进程重启后保留待播队列,但不能把旧进程的 active 当成仍在播放。"""
stale = dict(self._active_song or self.state.get("active") or {})
if not stale:
return False
# 旧 playback_id 对应的会话已经随上个进程中断,先可靠收口。
self._record_playback(stale, "error", stop_reason=reason, played_ms=played_ms)
# 该点歌仍应获得播放机会:清除旧播放代次字段后放回队首。
# request_id 保持不变,数据库中的同一请求会继续从 accepted 推进。
retry_item = dict(stale)
retry_item.pop("playback_id", None)
retry_item.pop("playback_started_at", None)
retry_item.pop("started_at", None)
retry_item.pop("start_attempts", None)
retry_item.pop("next_retry_at", None)
queue = self.state.setdefault("queue", [])
request_id = str(retry_item.get("request_id") or "")
def same_request(item: dict) -> bool:
item_request_id = str(item.get("request_id") or "")
if request_id and item_request_id:
return item_request_id == request_id
return (
str(item.get("id") or "") == str(retry_item.get("id") or "")
and str(item.get("uid") or "") == str(retry_item.get("uid") or "")
and str(item.get("requested_at") or "") == str(retry_item.get("requested_at") or "")
)
# 无论重复项原先位于哪里,都只保留一个恢复任务并置于队首。
queue[:] = [item for item in queue if not same_request(item)]
queue.insert(0, retry_item)
self._record_song_request(retry_item, "accepted", queue_position=1)
self._clear_active_song()
self._save()
self.logger.warning(
f"[点歌] 检测到上次进程遗留的活动歌曲,已收口旧播放会话并放回队首: "
f"{retry_item.get('name')} - {retry_item.get('artist')}"
)
return True
def _append_history(self, song: dict, status: str):
if not song:
return
item = dict(song)
item["status"] = status
item["finished_at"] = time.time()
self.state.setdefault("history", []).insert(0, item)
self.state["history"] = self.state["history"][:80]
def _stats_call(self, method: str, *args, **kwargs):
if not self.stats_store:
return
try:
getattr(self.stats_store, method)(*args, **kwargs)
except Exception as e:
self.logger.debug(f"[点歌统计] 写入失败: {e}")
@staticmethod
def _utc_from_timestamp(value: Any) -> str | None:
try:
return datetime.fromtimestamp(float(value), timezone.utc).isoformat()
except (TypeError, ValueError, OSError):
return None
def _record_song_request(self, item: dict, status: str, *, queue_position: int | None = None):
request_id = str(item.get("request_id") or "")
if not request_id:
return
uid = item.get("uid")
platform_user_id = str(uid) if isinstance(uid, int) and not isinstance(uid, bool) and uid > 0 else None
self._stats_call(
"record_song_request",
request_id,
requested_at_utc=self._utc_from_timestamp(item.get("requested_at")),
platform="bilibili",
platform_user_id=platform_user_id,
song_id=str(item.get("id") or "") or None,
song_name=str(item.get("name") or "") or None,
artist=str(item.get("artist") or "") or None,
source=str(item.get("source") or "viewer"),
status=status,
queue_position=queue_position,
payload={"source": str(item.get("source") or "viewer")},
)
def _record_playback(self, item: dict, status: str, *,
stop_reason: str | None = None,
played_ms: int | None = None):
playback_id = str(item.get("playback_id") or "")
if not playback_id:
return
ended = status != "playing"
self._stats_call(
"record_playback_session",
playback_id,
request_id=str(item.get("request_id") or "") or None,
song_id=str(item.get("id") or "") or None,
song_name=str(item.get("name") or "") or None,
started_at_utc=self._utc_from_timestamp(item.get("playback_started_at")),
ended_at_utc=datetime.now(timezone.utc).isoformat() if ended else None,
status=status,
played_ms=(
max(0, int(played_ms))
if played_ms is not None
else max(0, int(float(self.mpv.last_progress or 0) * 1000))
),
stop_reason=stop_reason,
payload={"source": str(item.get("source") or "viewer")},
)
def _cancel_queued_items(self, items: list[dict]):
for item in items:
self._record_song_request(item, "cancelled")
def remove_request(self, index: int | None = None, song_id: str = "") -> dict:
queue = self.state.setdefault("queue", [])
removed = None
if index is not None:
if index < 0 or index >= len(queue):
return {"success": False, "msg": "点歌序号不存在"}
removed = queue.pop(index)
elif song_id:
sid = str(song_id)
for i, item in enumerate(queue):
if str(item.get("id")) == sid:
removed = queue.pop(i)
break
if removed is None:
return {"success": False, "msg": "点歌不存在"}
else:
return {"success": False, "msg": "缺少点歌序号或歌曲ID"}
if str(removed.get("id")) == str(self._current_song_id):
self._current_song_id = None
self._record_song_request(removed, "cancelled")
self._save()
return {"success": True, "removed": removed}
def clear_requests(self) -> int:
removed = list(self.state.get("queue", []))
count = len(removed)
self.state["queue"] = []
self._cancel_queued_items(removed)
self._save()
return count
def move_request(self, song_id: str, to_index: int) -> dict:
queue = self.state.setdefault("queue", [])
sid = str(song_id or "")
if not sid:
return {"success": False, "msg": "缺少歌曲ID"}
current_index = next((i for i, item in enumerate(queue) if str(item.get("id")) == sid), -1)
if current_index < 0:
return {"success": False, "msg": "点歌不存在"}
item = queue.pop(current_index)
to_index = max(0, min(int(to_index), len(queue)))
queue.insert(to_index, item)
for position, queued in enumerate(queue, start=1):
self._record_song_request(queued, "accepted", queue_position=position)
self._save()
return {"success": True, "item": item, "position": to_index + 1}
def set_song_ban(self, song_id: str, *, ban: bool) -> dict:
sid = str(song_id or "").strip()
if not sid:
return {"success": False, "msg": "缺少歌曲ID"}
banned = set(str(x) for x in self.state.setdefault("banned_song_ids", []))
if ban:
banned.add(sid)
old_queue = self.state.get("queue", [])
removed_items = [item for item in old_queue if str(item.get("id")) == sid]
self.state["queue"] = [item for item in old_queue if str(item.get("id")) != sid]
self._cancel_queued_items(removed_items)
else:
banned.discard(sid)
self.state["banned_song_ids"] = sorted(banned)
self._save()
return {"success": True, "banned_song_ids": self.state["banned_song_ids"]}
def set_user_ban(self, uid: int, *, ban: bool) -> dict:
uid_str = str(uid)
banned = set(str(x) for x in self.state.setdefault("banned_user_ids", []))
removed = 0
if ban:
banned.add(uid_str)
old_queue = self.state.get("queue", [])
removed_items = [item for item in old_queue if str(item.get("uid")) == uid_str]
self.state["queue"] = [item for item in old_queue if str(item.get("uid")) != uid_str]
removed = len(removed_items)
self._cancel_queued_items(removed_items)
else:
banned.discard(uid_str)
self.state["banned_user_ids"] = sorted(banned)
self._save()
return {"success": True, "banned_user_ids": self.state["banned_user_ids"], "removed": removed}
def _china_bypass_headers(self) -> dict:
# 参考 BiliNCM-TS: 网易云旧 API 偶尔按来源 IP/客户端头限制结果,伪造国内转发头可提高命中率。
fake_ip = "218.75.111.114"
return {"X-Real-IP": fake_ip, "X-Forwarded-For": fake_ip}
async def search_songs(self, keyword: str, *, limit: int = 6) -> list[dict]:
api_base = str(self.cfg.get("api_base", "https://music.163.com")).rstrip("/")
id_match = re.search(r"(?:song\?id=|/song/|\bid[=/])([0-9]{5,})", keyword)
music_u = str(os.environ.get("NETEASE_MUSIC_U") or self.cfg.get("netease_music_u", "") or "").strip()
cookie = "os=pc; appver=2.9.8;"
if music_u:
cookie += f" MUSIC_U={music_u};"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://music.163.com/",
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
**self._china_bypass_headers(),
}
try:
async with aiohttp.ClientSession(headers=headers) as session:
if id_match:
song_id = id_match.group(1)
url = f"{api_base}/api/song/detail/?id={song_id}&ids=[{song_id}]"
async with session.get(url, timeout=10) as resp:
result = await resp.json(content_type=None)
songs = result.get("songs") or []
else:
url = api_base + "/api/search/get/web"
data = {"s": keyword, "type": "1", "limit": str(max(1, limit)), "offset": "0"}
async with session.post(url, data=data, timeout=10) as resp:
result = await resp.json(content_type=None)
songs = ((result.get("result") or {}).get("songs") or [])
results = []
for song in songs[: max(1, limit)]:
artists = song.get("artists") or song.get("ar") or []
artist = "/".join(a.get("name", "") for a in artists if a.get("name")) or "未知艺人"
duration_ms = song.get("duration", song.get("dt", 0))
try:
duration_ms = max(0, int(duration_ms or 0))
except (TypeError, ValueError):
duration_ms = 0
album = song.get("album") or song.get("al") or {}
cover = ""
if isinstance(album, dict):
cover = str(album.get("picUrl") or album.get("blurPicUrl") or "")
results.append({
"id": str(song.get("id", "")),
"name": song.get("name", "未知歌曲"),
"artist": artist,
"duration_ms": duration_ms,
"duration_sec": duration_ms // 1000,
"cover": cover,
"keyword": keyword,
})
return [item for item in results if item.get("id")]
except Exception as e:
self.logger.warning(f"[点歌] 搜索失败: {e}")
return []
async def search_song(self, keyword: str) -> dict | None:
results = await self.search_songs(keyword, limit=1)
return results[0] if results else None
async def add_request(self, uid: int, uname: str, song: dict, *, source: str = "viewer") -> dict:
song_id = str(song.get("id", ""))
if not song_id:
return {"success": False, "msg": "歌曲结果无效"}
if source != "admin":
if str(uid) in set(str(x) for x in self.state.get("banned_user_ids", [])):
return {"success": False, "msg": "你已被禁止点歌"}
if song_id in set(str(x) for x in self.state.get("banned_song_ids", [])):
return {"success": False, "msg": "这首歌已被后台禁点"}
max_duration = max(1, int(self.cfg.get("max_duration_sec", 600) or 600))
try:
duration_sec = max(0, int(song.get("duration_sec", 0) or 0))
except (TypeError, ValueError):
duration_sec = 0
if duration_sec <= 0:
return {"success": False, "msg": "无法获取歌曲时长,暂不允许点播"}
if duration_sec > max_duration:
minutes, seconds = divmod(duration_sec, 60)
return {
"success": False,
"msg": f"歌曲时长{minutes}:{seconds:02d},超过10分钟限制",
}
# 历史冷却检查: 1小时内点过的歌不能再点
if source != "admin" and self.cfg.get("dedupe_history", True):
cooldown = int(self.cfg.get("dedupe_cooldown_sec", 3600) or 3600)
now = time.time()
played_at = self.state.get("played_at") or []
# 清理过期记录(超过冷却时间)
played_at = [x for x in played_at
if x.get("id") != song_id or (now - float(x.get("at", 0))) < cooldown]
for entry in played_at:
if entry.get("id") == song_id:
remain = int(cooldown - (now - float(entry.get("at", 0))))
mins = remain // 60
return {
"success": False,
"msg": f"这首歌{mins}分钟前刚放过,1小时内不能再点",
}
if any(str(x.get("id")) == song_id for x in self.state.get("queue", [])):
return {"success": False, "msg": "这首歌已在点歌队列中"}
if self.state.get("active") and str((self.state.get("active") or {}).get("id")) == song_id:
return {"success": False, "msg": "这首歌正在播放中"}
item = dict(song)
request_id = uuid.uuid4().hex
item.update({
"request_id": request_id,
"uid": uid, "uname": uname, "requested_at": time.time(),
"source": source,
# 点歌只视为本次临时播放任务:即使歌曲原本已在当前循环队列里,播完也要从播放队列移除,避免下一轮循环再次播放。
"remove_after_play": True,
})
self.state.setdefault("queue", []).append(item)
position = len(self.state["queue"])
self._record_song_request(item, "accepted", queue_position=position)
self._save()
return {
"success": True,
"msg": "已加入点歌队列",
"position": position,
"request_id": request_id,
}
def _norm_song_text(self, text: str) -> str:
return re.sub(r"\s+", "", str(text or "").strip().lower())
def _song_matches_current(self, song: dict, music_state: dict) -> bool:
cur = music_state.get("current") or {}
title = self._norm_song_text(cur.get("title", ""))
artist = self._norm_song_text(cur.get("artist", ""))
song_name = self._norm_song_text(song.get("name", ""))
song_artist = self._norm_song_text(song.get("artist", ""))
if not song_name or not title:
return False
name_match = song_name in title or title in song_name
artist_match = bool(song_artist) and (song_artist in artist or artist in song_artist)
return name_match and (artist_match or not song_artist or not artist)
def _pop_queue_item_by_id(self, song_id: str) -> dict | None:
queue = self.state.setdefault("queue", [])
sid = str(song_id)
for i, item in enumerate(queue):
if str(item.get("id")) == sid:
return queue.pop(i)
return None
def _mark_played(self, song: dict):
now_ts = time.time()
self.state.setdefault("played_ids", []).append(str(song.get("id")))
self.state["played_ids"] = self.state["played_ids"][-300:]
self.state.setdefault("played_at", []).append({"id": str(song.get("id")), "at": now_ts})
cooldown = int(self.cfg.get("dedupe_cooldown_sec", 3600) or 3600)
self.state["played_at"] = [
x for x in self.state["played_at"]
if (now_ts - float(x.get("at", 0))) < cooldown
][-300:]
async def _remove_from_playlist_by_cdp(self, song: dict) -> bool:
"""从网易云当前播放队列中移除指定点歌。只改播放队列,不碰收藏/红心,避免触发“取消收藏”确认弹窗。"""
song_id = re.sub(r"\D", "", str(song.get("id", "")))
if not song_id:
return False
script = self._fiber_store_extract_js() + f"""
;(function(){{
if(!_ensureStore()) return {{ ok:false, reason:'no_store' }};
const store = window._reduxStore;
const state = store.getState();
const playingList = state.playingList || {{}};
const sid = String({song_id});
const getId = x => String(x && (x.id || x.trackId || x.resourceId || x.songId || (x.resource && x.resource.id) || (x.track && x.track.id)) || '');
const listKeys = [
'curPlayingList', 'currentPlayingList', 'playingList', 'playlist',
'playList', 'tracks', 'list', 'queue', 'playQueue', 'currentList'
];
let removed = 0;
let touchedKeys = [];
function filterList(value) {{
if (!Array.isArray(value)) return value;
const next = value.filter(x => getId(x) !== sid);
removed += value.length - next.length;
return next;
}}
const nextPlayingList = {{ ...playingList }};
for (const key of listKeys) {{
if (Array.isArray(playingList[key])) {{
const next = filterList(playingList[key]);
if (next.length !== playingList[key].length) {{
nextPlayingList[key] = next;
touchedKeys.push(key);
// 网易云部分版本 reducer action 不公开,直接同步修改当前 store 引用,再派发轻量 action 触发订阅刷新。
try {{ playingList[key].splice(0, playingList[key].length, ...next); }} catch(e) {{}}
}}
}}
}}
if (removed <= 0) return {{ ok:true, removed:0, keys:touchedKeys }};
const primaryList = Array.isArray(nextPlayingList.curPlayingList)
? nextPlayingList.curPlayingList
: (Array.isArray(nextPlayingList.currentPlayingList) ? nextPlayingList.currentPlayingList : []);
const candidates = [
{{ type:'playingList/setPlayingList', payload: nextPlayingList }},
{{ type:'playingList/updatePlayingList', payload: nextPlayingList }},
{{ type:'playingList/setCurPlayingList', payload: primaryList }},
{{ type:'playingList/updateCurPlayingList', payload: primaryList }},
{{ type:'playingList/setCurrentPlayingList', payload: primaryList }},
{{ type:'playingList/removeTrack', payload: {{ id: sid }} }},
{{ type:'playingList/removeSong', payload: {{ id: sid }} }},
{{ type:'playingList/remove', payload: sid }},
{{ type:'@@BGI/PLAYLIST_REMOVED', payload: {{ id: sid, at: Date.now() }} }}
];
for (const action of candidates) {{ try {{ store.dispatch(action); }} catch(e) {{}} }}
return {{ ok:true, removed, keys:touchedKeys }};
}})()
"""
result = await self._eval_cdp_value(script)
ok = bool(result and result.get("ok")) if isinstance(result, dict) else bool(result)
if ok:
removed = result.get("removed", "?") if isinstance(result, dict) else "?"
keys = ",".join(result.get("keys", [])) if isinstance(result, dict) else ""
self.logger.info(f"[点歌] 已尝试从播放队列移除点歌: {song.get('name')} - {song.get('artist')} ({song_id}), removed={removed}, keys={keys or '-'}")
return ok
async def _add_to_playlist_tail_by_cdp(self, song: dict) -> bool:
"""通过CDP把点歌的歌曲加到播放列表末尾(不会立即播放,等当前歌放完自然到这首)。
用 addToPlayList actionId, 不会再触发'取消收藏'弹窗。"""
song_id = re.sub(r"\D", "", str(song.get("id", "")))
if not song_id:
return False
# addToPlayList: 加入播放列表末尾(不切换当前播放)
script = self._fiber_store_extract_js() + f";if (_ensureStore()) {{ window._reduxStore.dispatch({{ type:'async:action/doAction', payload:{{ actionId:'addToPlayList', data:{{ resource:{{ id:String({song_id}), duration:0 }}, resourceType:'track', eventType:'click' }} }} }}); }}"
ok = await self._send_cdp_command(script)
if ok:
self.logger.info(f"[点歌] 已加入播放列表末尾: {song.get('name')} - {song.get('artist')} ({song_id})")
return ok
def _cdp_port(self) -> int:
return int(self.cfg.get("cdp_port", 9222) or 9222)
def _is_port_open(self, port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return True
except OSError:
return False
def _guess_client_exe(self) -> str:
configured = str(self.cfg.get("client_exe", "") or "").strip()
if configured and Path(configured).exists():
return configured
candidates = [
Path(os.environ.get("LOCALAPPDATA", "")) / "Netease" / "CloudMusic" / "cloudmusic.exe",
Path(os.environ.get("PROGRAMFILES", "")) / "Netease" / "CloudMusic" / "cloudmusic.exe",
Path(os.environ.get("PROGRAMFILES(X86)", "")) / "Netease" / "CloudMusic" / "cloudmusic.exe",
]
for path in candidates:
if path.exists():
return str(path)
return ""
def _ensure_cdp_available(self) -> bool:
port = self._cdp_port()
if self._is_port_open(port):
return True
if not self.cfg.get("auto_launch_cdp", True) or self._cdp_launch_attempted:
return False
self._cdp_launch_attempted = True
exe = self._guess_client_exe()
if not exe:
self._warn_open_fail("未找到网易云 cloudmusic.exe,无法以 CDP 调试端口启动")
return False
if os.name == "nt":
try:
subprocess.run(
["taskkill", "/F", "/IM", str(self.cfg.get("client_process", "cloudmusic.exe") or "cloudmusic.exe")],
capture_output=True, text=True, creationflags=0x08000000, timeout=5,
)
except Exception:
pass
try:
subprocess.Popen(
[exe, f"--remote-debugging-port={port}"],
cwd=str(Path(exe).parent), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=0x08000000 if os.name == "nt" else 0,
)
self.logger.info(f"[点歌] 已按 BiliNCM 方案用 CDP 端口 {port} 启动网易云")
time.sleep(3)
return self._is_port_open(port)
except Exception as e:
self._warn_open_fail(f"启动网易云 CDP 模式失败: {e}")
return False
async def _get_cdp_ws_url(self) -> str:
port = self._cdp_port()
if not self._ensure_cdp_available():
return ""
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"http://127.0.0.1:{port}/json", timeout=3) as resp:
if resp.status != 200:
return ""
targets = await resp.json(content_type=None)
for target in targets:
if target.get("type") == "page" and ("orpheus" in str(target.get("url", "")) or "music.163.com" in str(target.get("url", ""))):
return target.get("webSocketDebuggerUrl", "")
for target in targets:
if target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
return target.get("webSocketDebuggerUrl", "")
except Exception as e:
self.logger.debug(f"[点歌] 获取 CDP Target 失败: {e}")
return ""
async def _eval_cdp_value(self, script: str):
ws_url = await self._get_cdp_ws_url()
if not ws_url:
self._warn_open_fail(f"CDP 端口 {self._cdp_port()} 不可用,请让程序自动重启网易云或手动加 --remote-debugging-port={self._cdp_port()}")
return None
try:
async with websockets.connect(ws_url, open_timeout=3, close_timeout=1) as ws:
payload = {
"id": 1,
"method": "Runtime.evaluate",
"params": {"expression": script, "returnByValue": True, "awaitPromise": True},
}
await ws.send(json.dumps(payload))
deadline = time.time() + 3
while time.time() < deadline:
raw = await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - time.time()))
msg = json.loads(raw)
if msg.get("id") == 1:
if msg.get("result", {}).get("exceptionDetails"):
self.logger.warning(f"[点歌] CDP 注入异常: {msg['result']['exceptionDetails']}")
return None
return (((msg.get("result") or {}).get("result") or {}).get("value"))
except Exception as e:
self.logger.warning(f"[点歌] CDP 指令失败: {e}")
return None
async def _send_cdp_command(self, script: str) -> bool:
return await self._eval_cdp_value(script) is not None
def _fiber_store_extract_js(self) -> str:
return r'''
function _ensureStore() {
try {
if (window._reduxStore) return true;
const rootEl = document.querySelector('#root');
const root = window._fiberRoot || (rootEl && rootEl._reactRootContainer && rootEl._reactRootContainer._internalRoot);
if (!root) return false;
let queue = [root.current || root];
let visited = 0;
while (queue.length > 0) {
let node = queue.shift();
if (!node) continue;
visited++;
if (visited > 20000) break;
if (node.memoizedProps && node.memoizedProps.store) { window._reduxStore = node.memoizedProps.store; return true; }
if (node.stateNode && node.stateNode.store) { window._reduxStore = node.stateNode.store; return true; }
let child = node.child;
while (child) { queue.push(child); child = child.sibling; }
}
return false;
} catch(err) { return false; }
}
'''
async def _get_playlist_ids_via_cdp(self) -> set:
"""通过CDP读取网易云当前播放列表的所有歌曲ID, 用于判断点歌是否已在歌单中。"""
script = self._fiber_store_extract_js() + r'''
(function(){
if(!_ensureStore()) return [];
var state = window._reduxStore.getState();
var list = (state.playingList && state.playingList.curPlayingList) || [];
return list.map(function(x){ return String(x.id || x.trackId || x.resourceId || ''); }).filter(Boolean);
})()
'''
ws_url = await self._get_cdp_ws_url()
if not ws_url:
return set()
try:
async with websockets.connect(ws_url, open_timeout=1.5, close_timeout=0.5) as ws:
await ws.send(json.dumps({
"id": 1,
"method": "Runtime.evaluate",
"params": {"expression": script, "returnByValue": True, "awaitPromise": True},
}))
deadline = time.time() + 2
while time.time() < deadline:
raw = await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - time.time()))
msg = json.loads(raw)
if msg.get("id") == 1:
value = (((msg.get("result") or {}).get("result") or {}).get("value"))
if isinstance(value, list):
return set(str(x) for x in value)
return set()
except Exception:
pass
return set()
async def _play_song_by_cdp(self, song: dict, force: bool = True) -> bool:
song_id = re.sub(r"\D", "", str(song.get("id", "")))
if not song_id:
return False
action = "play" if force else "addToPlayList"
event_type = "dblclick" if force else "click"
script = self._fiber_store_extract_js() + f";if (_ensureStore()) {{ window._reduxStore.dispatch({{ type:'async:action/doAction', payload:{{ actionId:'{action}', data:{{ resource:{{ id:String({song_id}), duration:0 }}, resourceType:'track', eventType:'{event_type}' }} }} }}); }}"
ok = await self._send_cdp_command(script)
if ok:
self.logger.info(f"[点歌] 已通过CDP {'播放' if force else '加入播放列表'}: {song.get('name')} - {song.get('artist')} ({song_id})")
return ok
async def _force_play_without_playlist_by_cdp(self, song: dict) -> bool:
"""直接播放队首点歌,不预先插入播放列表,避免后点歌曲反复抢到下一首位置。"""
return await self._play_song_by_cdp(song, force=True)
async def _play_next_by_cdp(self) -> bool:
script = self._fiber_store_extract_js() + ";if(_ensureStore()){window._reduxStore.dispatch({type:'async:action/doAction',payload:{actionId:'playNext',data:{eventType:'click'}}});}"
return await self._send_cdp_command(script)
def _find_client_window_by_uia(self):
try:
import uiautomation as auto
keyword = str(self.cfg.get("client_process", "cloudmusic.exe")).replace(".exe", "").lower()
root = auto.GetRootControl()
candidates = []
def walk(ctrl, depth=0):
if depth > 3:
return
try:
name = (ctrl.Name or "")
cls = (ctrl.ClassName or "")
text = (name + " " + cls).lower()
if "网易云音乐" in name or "cloudmusic" in text or "orpheus" in text or keyword in text:
candidates.append(ctrl)
for child in ctrl.GetChildren():
walk(child, depth + 1)
except Exception:
pass
walk(root)
if candidates:
return candidates[0]
except Exception as e:
self.logger.debug(f"[点歌] UIA 查找网易云窗口失败: {e}")
return None
def _find_client_window_by_pid(self):
if os.name != "nt":
return None
proc_name = str(self.cfg.get("client_process", "cloudmusic.exe") or "cloudmusic.exe").lower()
try:
output = subprocess.check_output(
["tasklist", "/FI", f"IMAGENAME eq {proc_name}", "/FO", "CSV", "/NH"],
text=True, encoding="gbk", errors="ignore", creationflags=0x08000000
)
pids = []
for line in output.splitlines():
parts = [p.strip().strip('"') for p in line.split(",")]
if len(parts) >= 2 and parts[0].lower() == proc_name:
try:
pids.append(int(parts[1]))
except Exception:
pass
if not pids:
return None
user32 = ctypes.windll.user32
hwnds = []
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def enum_proc(hwnd, lparam):
if not user32.IsWindowVisible(hwnd):
return True
pid = ctypes.c_ulong()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value in pids:
length = user32.GetWindowTextLengthW(hwnd)
title = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, title, length + 1)
hwnds.append((hwnd, title.value))
return True
user32.EnumWindows(EnumWindowsProc(enum_proc), 0)
if not hwnds:
return None
hwnd, title = max(hwnds, key=lambda x: len(x[1] or ""))
SW_RESTORE = 9
user32.ShowWindow(hwnd, SW_RESTORE)
user32.SetForegroundWindow(hwnd)
self.logger.info(f"[点歌] 已通过进程窗口激活网易云: hwnd={hwnd}, title={title or '-'}")
return hwnd
except Exception as e:
self.logger.debug(f"[点歌] 进程窗口查找失败: {e}")
return None
def _find_client_window(self):
return self._find_client_window_by_uia() or self._find_client_window_by_pid()
def _ensure_client_window(self):
win = self._find_client_window()
if win:
try:
if hasattr(win, "SetActive"):
win.SetActive()
except Exception:
pass
return win
exe = str(self.cfg.get("client_exe", "")).strip()
if exe and Path(exe).exists():
try:
subprocess.Popen([exe], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
win = self._find_client_window()
if win:
try:
if hasattr(win, "SetActive"):
win.SetActive()
except Exception:
pass
return win
except Exception as e:
self.logger.warning(f"[点歌] 启动网易云客户端失败: {e}")
return None
def _play_song_by_client_ui(self, song: dict) -> bool:
"""控制网易云客户端搜索并播放。依赖 uiautomation,避免只打开网页版。"""
try:
import uiautomation as auto
except Exception:
self._warn_open_fail("缺少 uiautomation,无法控制网易云客户端播放")
return False
win = self._ensure_client_window()
if not win:
self._warn_open_fail("未找到网易云客户端窗口,请先打开网易云音乐客户端")
return False
keyword = f"{song.get('name', '')} {song.get('artist', '')}".strip()
wait_sec = float(self.cfg.get("ui_wait_sec", 0.6) or 0.6)
enter_count = int(self.cfg.get("play_enter_count", 2) or 2)
try:
auto.HotKey(auto.ModifierKey.Control, auto.Keys.VK_F)
time.sleep(wait_sec)
auto.SendKeys("{Ctrl}a")
time.sleep(0.1)
auto.SendKeys(keyword, interval=0.01)
time.sleep(wait_sec)
for _ in range(max(1, enter_count)):
auto.SendKeys("{Enter}")
time.sleep(wait_sec)
self.logger.info(f"[点歌] 已控制网易云客户端搜索播放: {keyword}")
return True
except Exception as e:
self.logger.warning(f"[点歌] 控制网易云客户端失败: {e}")
return False
def _warn_open_fail(self, msg: str):
now = time.time()
if now - self._last_open_fail_log >= 30:
self.logger.warning(f"[点歌] {msg}")
self._last_open_fail_log = now
async def _play_song_with_mpv(self, song: dict, *, start_at: float = 0.0) -> bool:
self.resolver.update_api_base(str(self.cfg.get("api_base", "https://music.163.com")))
resolved = await self.resolver.resolve(song)
if not resolved:
return False
metadata = dict(song)
if not metadata.get("cover"):
detail = await self.resolver.fetch_song_detail(str(song.get("id", "")))
if detail and detail.get("cover"):
metadata["cover"] = detail["cover"]
song["cover"] = detail["cover"]
metadata["audio_source"] = resolved.get("source", "netease.outer")
return await self.mpv.play(str(resolved.get("url", "")), metadata, start_at=start_at)
async def _open_song(self, song: dict, force: bool = False) -> bool:
"""点歌统一由独立 mpv 播放,禁止旧 CDP/UI 自动化重新介入。"""
method = str(self.cfg.get("play_method", "mpv") or "mpv").strip().lower()
if method != "mpv":
self.logger.error(f"[点歌] 已禁用旧播放方式: {method},请将 play_method 设为 mpv")
return False
return await self._play_song_with_mpv(song)
async def _start_queued_song(self, item: dict, *, reason: str) -> bool:
item["playback_id"] = uuid.uuid4().hex
item["playback_started_at"] = time.time()
if not await self._open_song(item, force=True):
item.pop("playback_id", None)
item.pop("playback_started_at", None)
return False
item.pop("start_attempts", None)
item.pop("next_retry_at", None)
self._reload_failures = 0
self._background_song = None
self._set_active_song(item, source=item.get("source", "viewer"))
self._record_song_request(item, "playing")
self._record_playback(item, "playing")
self._player_snapshot = await self.mpv.snapshot()
self._save()
self.logger.info(f"[点歌] {reason}: {item.get('name')} - {item.get('artist')}")
return True
def _background_enabled(self) -> bool:
return bool(
self.cfg.get("background_playlist_enabled", False)
and str(self.cfg.get("background_playlist_url", "") or "").strip()
)
async def _load_background_playlist(self, *, force: bool = False) -> bool:
if not self._background_enabled():
self._background_tracks = []
self._background_song = None
return False
now = time.time()
try:
refresh_sec = max(60.0, float(self.cfg.get("background_playlist_refresh_sec", 3600) or 3600))
except (TypeError, ValueError):
refresh_sec = 3600.0
if self._background_tracks and not force and now - self._background_loaded_at < refresh_sec:
return True
if not force and now < self._background_retry_at:
return bool(self._background_tracks)
playlist_url = str(self.cfg.get("background_playlist_url", "") or "").strip()
playlist = await self.resolver.fetch_playlist(playlist_url)
if not playlist:
try:
retry_sec = max(5.0, float(self.cfg.get("background_playlist_retry_sec", 30) or 30))
except (TypeError, ValueError):
retry_sec = 30.0
self._background_retry_at = now + retry_sec
self._warn_open_fail("背景歌单获取失败,稍后重试")
return bool(self._background_tracks)
previous_id = str(self.state.get("background_playlist_id") or "")
self._background_tracks = list(playlist.get("songs") or [])
self._background_loaded_at = now
self._background_retry_at = 0.0
self.state["background_playlist_id"] = str(playlist.get("id") or "")
self.state["background_playlist_name"] = str(playlist.get("name") or "")
if previous_id != self.state["background_playlist_id"]:
self.state["background_playlist_index"] = 0
if self._background_tracks:
self.state["background_playlist_index"] = int(
self.state.get("background_playlist_index", 0) or 0
) % len(self._background_tracks)
self._save()
self.logger.info(
f"[背景歌单] 已加载 {self.state['background_playlist_name']}: "
f"{len(self._background_tracks)} 首"
)
return bool(self._background_tracks)
async def _start_background_song(self) -> bool:
if not await self._load_background_playlist():
return False
total = len(self._background_tracks)
if not total:
return False
start_index = int(self.state.get("background_playlist_index", 0) or 0) % total
for offset in range(total):
index = (start_index + offset) % total
song = dict(self._background_tracks[index])
song["background_index"] = index
song["source"] = "background"
song["playback_id"] = uuid.uuid4().hex
song["playback_started_at"] = time.time()
if await self._play_song_with_mpv(song):
self._background_song = song
self._record_playback(song, "playing")
self._reload_failures = 0
self.state["background_playlist_index"] = (index + 1) % total
self._player_snapshot = await self.mpv.snapshot()
self._save()
self.logger.info(
f"[背景歌单] 开始播放 {index + 1}/{total}: "
f"{song.get('name')} - {song.get('artist')}"
)
return True
self.logger.warning(
f"[背景歌单] 跳过无法播放的歌曲 {index + 1}/{total}: {song.get('name')}"
)
self._background_retry_at = time.time() + 30
return False
async def _maintain_background_song(self) -> bool:
if not self._background_song:
return False
result = await self.mpv.maintain(
stall_seconds=float(self.cfg.get("mpv_stall_seconds", 12) or 12)
)
self._player_snapshot = result.get("snapshot") or await self.mpv.snapshot()
action = str(result.get("action", "none"))
if action == "ended":
finished = dict(self._background_song)
self._record_playback(finished, "played", stop_reason="ended")
self._background_song = None
self.logger.info(f"[背景歌单] 播放完毕: {finished.get('name')} - {finished.get('artist')}")
return False
if action in {"reload_required", "failed"}:
progress = float(result.get("progress", self.mpv.last_progress) or 0)
self._reload_failures += 1
if self._reload_failures <= 3 and await self._play_song_with_mpv(self._background_song, start_at=progress):
return True
failed = dict(self._background_song)
await self.mpv.stop()
self._record_playback(failed, "error", stop_reason="recovery_failed")
self._background_song = None
self._reload_failures = 0
self.logger.warning(f"[背景歌单] 播放失败,切换下一首: {failed.get('name')}")
return False
return True
async def play_now(self, *, song_id: str = "", index: int | None = None) -> dict:
async with self._play_lock:
queue = self.state.setdefault("queue", [])
item = None
original_index = 0
if index is not None:
if index < 0 or index >= len(queue):
return {"success": False, "msg": "点歌序号不存在"}
original_index = index
item = queue.pop(index)
elif song_id:
for i, queued in enumerate(queue):
if str(queued.get("id")) == str(song_id):
original_index = i
item = queue.pop(i)
break
elif queue:
item = queue.pop(0)
if not item:
return {"success": False, "msg": "点歌不存在"}
previous = dict(self._active_song) if self._active_song else None
previous_background = dict(self._background_song) if self._background_song else None
previous_url = self.mpv.current_url
previous_progress = self.mpv.last_progress
if await self._start_queued_song(item, reason="后台立即切歌"):
previous_played_ms = max(0, int(float(previous_progress or 0) * 1000))
if previous:
self._record_song_request(previous, "skipped")
self._record_playback(
previous,
"skipped",
stop_reason="interrupted",
played_ms=previous_played_ms,
)
self._append_history(previous, "interrupted")
self._save()
elif previous_background:
self._record_playback(
previous_background,
"skipped",
stop_reason="interrupted",
played_ms=previous_played_ms,
)
return {"success": True, "item": item}
queue.insert(min(original_index, len(queue)), item)
previous_item = previous or previous_background
if previous_item and previous_url:
restored = await self.mpv.play(previous_url, previous_item, start_at=previous_progress)
if restored and previous:
self._active_song = previous
self._current_song_id = str(previous.get("id", "") or "")
self.state["active"] = previous
elif restored and previous_background:
self._background_song = previous_background
elif not restored and previous:
self._record_song_request(previous, "error")
self._record_playback(
previous,
"error",
stop_reason="restore_failed",
played_ms=max(0, int(float(previous_progress or 0) * 1000)),
)
self._append_history(previous, "play_error")
self._clear_active_song()
elif not restored and previous_background:
self._record_playback(
previous_background,
"error",
stop_reason="restore_failed",
played_ms=max(0, int(float(previous_progress or 0) * 1000)),
)
self._background_song = None
self._save()
return {"success": False, "msg": "切歌失败,请检查mpv或歌曲音源"}
async def skip_current(self) -> dict:
async with self._play_lock:
current = dict(self._active_song) if self._active_song else dict(self.state.get("active") or {})
if not current:
if self._background_song:
skipped = dict(self._background_song)
await self.mpv.stop()
self._record_playback(skipped, "skipped", stop_reason="manual_skip")
self._background_song = None
self._player_snapshot = await self.mpv.snapshot()
self.logger.info(f"[背景歌单] 已跳过: {skipped.get('name')} - {skipped.get('artist')}")
return {"success": True, "msg": "已跳过当前背景歌曲"}
return {"success": False, "msg": "当前没有可跳过的歌曲"}
await self.mpv.stop()
self._record_song_request(current, "skipped")
self._record_playback(current, "skipped", stop_reason="manual_skip")
self._append_history(current, "skipped")
self._clear_active_song()
self._player_snapshot = await self.mpv.snapshot()
queue = self.state.setdefault("queue", [])
if queue:
next_item = queue.pop(0)
if await self._start_queued_song(next_item, reason="后台跳过当前并播放下一首"):
return {"success": True, "item": next_item}
queue.insert(0, next_item)
self._save()
return {"success": True, "msg": "已跳过当前歌曲,下一首暂时无法播放"}
self._save()
return {"success": True, "msg": "已跳过当前歌曲"}
async def _finish_active(self, status: str, *, mark_played: bool = False,
played_ms: int | None = None):
finished = dict(self._active_song or {})
if not finished:
return
if mark_played:
self._mark_played(finished)
db_status = "error" if status == "play_error" else status
self._record_song_request(finished, db_status)
self._record_playback(
finished,
db_status,
stop_reason="ended" if db_status == "played" else "recovery_failed" if db_status == "error" else db_status,
played_ms=played_ms,
)
self._append_history(finished, status)
self._clear_active_song()
self._reload_failures = 0
self._save()
async def _recover_stream(self, progress: float) -> bool:
if not self._active_song:
return False
self._reload_failures += 1
if self._reload_failures > 3:
return False
self.logger.warning(
f"[点歌] 音频流异常,重新解析URL并从{progress:.1f}秒续播 "
f"({self._reload_failures}/3): {self._active_song.get('name')}"
)
return await self._play_song_with_mpv(self._active_song, start_at=progress)
async def run(self, music_state_getter=None):
self.logger.info("[点歌] 独立mpv播放调度启动")
while True:
await asyncio.sleep(1)
try:
if not self.cfg.get("enabled", True) or not self.cfg.get("auto_open", True):
continue
async with self._play_lock:
if self._active_song and (
self.mpv.desired_state != "playing" or not self.mpv.current_url
):
# 运行期兜底:持久化 active 与 mpv 实际状态失配时,
# 不能在 maintain(action=none) 分支永久阻塞后续队列。
stale_progress_ms = max(0, int(float(self.mpv.last_progress or 0) * 1000))
await self.mpv.stop()
self._recover_stale_active(
reason="player_state_missing",
played_ms=stale_progress_ms,
)
if self._active_song:
result = await self.mpv.maintain(
stall_seconds=float(self.cfg.get("mpv_stall_seconds", 12) or 12)
)
self._player_snapshot = result.get("snapshot") or await self.mpv.snapshot()
action = str(result.get("action", "none"))
if action == "ended":
finished = dict(self._active_song)
ended_progress = float(result.get("progress", 0) or 0)
await self._finish_active(
"played",
mark_played=True,
played_ms=max(0, int(ended_progress * 1000)),
)
self.logger.info(f"[点歌] 播放完毕: {finished.get('name')} - {finished.get('artist')}")
elif action == "reload_required":
progress = float(result.get("progress", self.mpv.last_progress) or 0)
if not await self._recover_stream(progress):
failed = dict(self._active_song)
await self.mpv.stop()
await self._finish_active("play_error")
self._player_snapshot = await self.mpv.snapshot()
self.logger.error(f"[点歌] 连续恢复失败,已跳过: {failed.get('name')} - {failed.get('artist')}")
elif action == "failed":
progress = self.mpv.last_progress
if not await self._recover_stream(progress):
failed = dict(self._active_song)
await self.mpv.stop()
await self._finish_active("play_error")
self._player_snapshot = await self.mpv.snapshot()
self.logger.error(f"[点歌] 播放器恢复失败,已跳过: {failed.get('name')} - {failed.get('artist')}")
continue
queue = self.state.setdefault("queue", [])
if self._background_song:
background_still_playing = await self._maintain_background_song()
if background_still_playing:
# 普通点歌只入队,等待当前背景歌曲自然播放完毕。
continue
if not queue:
if self._background_enabled():
await self._start_background_song()
else:
self._player_snapshot = await self.mpv.snapshot()
continue
now = time.time()
started = queue.pop(0)
next_retry_at = float(started.get("next_retry_at", 0) or 0)
if next_retry_at > now:
queue.append(started)
self._save()
continue
previous_background = dict(self._background_song) if self._background_song else None
previous_background_progress = self.mpv.last_progress
if not await self._start_queued_song(started, reason="播放队首"):
attempts = int(started.get("start_attempts", 0) or 0) + 1
max_attempts = max(1, int(self.cfg.get("queue_start_max_attempts", 3) or 3))
if self.resolver.last_error_code == "preview_only":
attempts = max_attempts
if attempts >= max_attempts:
started["start_attempts"] = attempts
self._record_song_request(started, "error")
self._append_history(started, "play_error")
self.logger.error(
f"[点歌] 连续{attempts}次无法解析或启动,已跳过并继续队列: "
f"{started.get('name')} - {started.get('artist')}"
)
else:
try:
retry_sec = max(5.0, float(self.cfg.get("queue_start_retry_sec", 15) or 15))
except (TypeError, ValueError):
retry_sec = 15.0
started["start_attempts"] = attempts
started["next_retry_at"] = now + retry_sec
queue.append(started)
self.logger.warning(
f"[点歌] 无法解析或启动,{retry_sec:.0f}秒后重试 "
f"({attempts}/{max_attempts}),已让出队首: "
f"{started.get('name')} - {started.get('artist')}"
)
if previous_background:
# mpv.play 失败会清空底层播放状态,不能保留一个失真的背景活动标记。
self._record_playback(
previous_background,
"error",
stop_reason="interrupted_start_failed",
played_ms=max(0, int(float(previous_background_progress or 0) * 1000)),
)
self._background_song = None
self._save()
except asyncio.CancelledError:
raise
except Exception as e:
self.logger.warning(f"[点歌] 调度异常: {e}")
# ============== 积分扣除调度器 ==============
class PointsScheduler:
"""队首确认上号后每分钟扣1积分,可扣到负分,不因积分耗尽中断 BGI。"""
def __init__(self, user_mgr: UserManager, queue_mgr: QueueManager,
runner: BetterGIRunner, logger: logging.Logger,
broadcaster: Broadcaster = None):
self.user_mgr = user_mgr
self.queue_mgr = queue_mgr
self.runner = runner
self.logger = logger
self.broadcaster = broadcaster
self._stop = False
self._warned_negative_users = set()
def set_broadcaster(self, broadcaster: Broadcaster):
self.broadcaster = broadcaster
async def run(self):
"""后台循环: 每60秒扣1积分"""
self.logger.info("[积分调度] 启动")
while not self._stop:
await asyncio.sleep(60) # 每分钟检查一次
try:
await self._deduct_once()
except Exception as e:
self.logger.error(f"[积分调度] 异常: {e}")
async def handle_current_admin_depleted(self, uid: int, points: int, reason: str = "积分耗尽") -> bool:
"""当前队首积分已到0或负数时立即移出队列。
如果正在跑用户配置组,则保留 BetterGI 继续运行并继续扣旧用户负分;
如果没有配置组在跑,则只清理队首/登录/计费状态并提升下一位。
"""
state = self.queue_mgr.state
if points > 0 or state.get("current_admin_uid") != uid:
return False
uname = self.user_mgr.users.get(str(uid), {}).get("uname", "?")
running_group = state.get("current_group")
if running_group and not state.get("default_running"):
result = self.queue_mgr.remove_current_admin_keep_running(
action="removed",
reason="points_depleted",
)
self.logger.warning(
f"[积分耗尽] {uname}({uid}) {reason}后积分{points}, 已移出队列但保留BGI继续运行"
)
else:
if uid in state.get("queue", []):
state["queue"].remove(uid)
state["current_admin_uid"] = None
state["login_status"] = None
state["login_started_at"] = None
state["confirm_started_at"] = None
state["admin_window_end"] = None
state["billing_started_at"] = None
state["billing_last_at"] = None
state["billing_uid"] = None
self.queue_mgr._promote_next()
self.queue_mgr._save()
if self.queue_mgr.stats_store:
self.queue_mgr.stats_store.record_queue_event(
"main",
"removed",
item_id=str(uid),
position=1,
queue_size=len(state.get("queue", [])),
payload={
"platform": "bilibili",
"platform_user_id": str(uid),
"reason": "points_depleted",
"promoted_uid": state.get("current_admin_uid"),
},
)
result = {"promoted_uid": state.get("current_admin_uid")}
self.logger.warning(
f"[积分耗尽] {uname}({uid}) {reason}后积分{points}, 已移出队列"
)
promoted_uid = result.get("promoted_uid")
if promoted_uid:
promoted_uname = self.user_mgr.users.get(str(promoted_uid), {}).get("uname", "?")
self.logger.info(f"[顶号] {promoted_uname}({promoted_uid}) 成为新队首,可发送上号")
if self.broadcaster:
await self.broadcaster.broadcast(
f"「{promoted_uname}」成为新队首,90秒内发送\"上号\"开始",
tts=True,
danmu=True,
)
elif self.broadcaster:
await self.broadcaster.broadcast(
f"「{uname}」积分已耗尽,已移出队列",
tts=True,
danmu=True,
)
return True
async def _deduct_once(self):
state = self.queue_mgr.state
# 默认薄荷不扣;用户确认账号后即开始扣分,即使尚未执行配置组也扣。
if state.get("default_running"):
return
billing_uid = state.get("billing_uid") or state.get("current_admin_uid")
if not billing_uid:
return
if state.get("default_running"):
return
# 有当前队首时,必须是已确认登录后才开始扣;积分耗尽移出队列后,billing_uid 会继续保留用于扣到负分。
if state.get("current_admin_uid") == billing_uid and state.get("login_status") != "logged_in":
return
if not state.get("billing_started_at"):
now = datetime.now().isoformat()
state["billing_started_at"] = now
state["billing_last_at"] = now
state["billing_uid"] = billing_uid
self.queue_mgr._save()
return
old_points = self.user_mgr.get_points(billing_uid)
new_points = await self.user_mgr.add_points(
billing_uid,
-POINTS_PER_MINUTE,
reason="queue_billing",
reference_type="group_run" if state.get("current_group_run_id") else "queue_session",
reference_id=str(state.get("current_group_run_id") or billing_uid),
)
state["billing_last_at"] = datetime.now().isoformat()
state["billing_uid"] = billing_uid
self.queue_mgr._save()
uname = self.user_mgr.users.get(str(billing_uid), {}).get("uname", "?")
running_name = state.get("current_group") or "等待执行"
self.logger.info(
f"[积分扣除] {uname}({billing_uid}) {old_points}->{new_points} "
f"(状态:{running_name})"
)
if self.broadcaster:
await self.broadcaster.broadcast(
f"「{uname}」扣除{POINTS_PER_MINUTE}积分,当前余额{new_points}",
tts=True,
danmu=True,
)
# 第一次扣到 0 或负数时,用户失去队首/二级权限并从队列移除,但不 kill BGI,配置组继续自然运行。
if new_points <= 0 and state.get("current_admin_uid") == billing_uid:
await self.handle_current_admin_depleted(billing_uid, new_points, "分钟扣分")
return
if new_points <= 0 and billing_uid not in self._warned_negative_users:
self._warned_negative_users.add(billing_uid)
self.logger.warning(
f"[积分负扣] {uname}({billing_uid}) 积分{new_points}, BGI继续运行"
)
def stop(self):
self._stop = True
# ============== 弹幕指令处理 ==============
class CommandHandler:
"""处理弹幕指令,整合所有模块。"""
def __init__(self, config: Config, user_mgr: UserManager,
queue_mgr: QueueManager, runner: BetterGIRunner,
log_monitor: BgiLogMonitor, points_sched: PointsScheduler,
logger: logging.Logger, broadcaster: Broadcaster = None,
stats_store: StatsStore | None = None,
redemption_store: RedemptionCodeStore | None = None):
self.config = config
self.user_mgr = user_mgr
self.queue_mgr = queue_mgr
self.runner = runner
self.log_monitor = log_monitor
self.points_sched = points_sched
self.logger = logger
self.broadcaster = broadcaster
self.stats_store = stats_store
self.redemption_store = redemption_store
self.recent_danmu = []
self.max_danmu = 200
self.recent_gifts = []
self.max_gifts = 100
self._seen_gift_events: dict[str, float] = {}
self._pending_gift_thanks: dict[tuple[int, str, str], dict[str, Any]] = {}
self._gift_tasks: set[asyncio.Task] = set()
self.system = None # 由QueueSystem设置,用于启动默认组等
self.login_monitor = None # 由QueueSystem设置
self._rule_last_trigger_at: dict[int, float] = {}
self._pending_group_confirmations: dict[int, dict[str, Any]] = {}
self._tts_category_context = contextvars.ContextVar(
"command_tts_category",
default=None,
)
def _tts_category_enabled(self, category: str | None) -> bool:
category = category or self._tts_category_context.get()
if not category:
return True
categories = self.config.broadcast_cfg.get("tts_categories", {})
return bool(categories.get(category, True))
async def broadcast(
self,
text: str,
tts: bool = True,
danmu: bool = True,
category: str | None = None,
):
"""通过 broadcaster 发弹幕和 TTS;分类开关只控制语音,不影响文字回复。"""
if self.broadcaster:
resolved_category = category or self._tts_category_context.get()
await self.broadcaster.broadcast(
text,
tts=tts and self._tts_category_enabled(resolved_category),
danmu=danmu,
tts_category=resolved_category,
)
@staticmethod
def _gift_event_key(gift: dict) -> str:
transaction_id = str(gift.get("transaction_id") or "").strip()
if transaction_id:
return f"tid:{transaction_id}"
batch_combo_id = str(gift.get("batch_combo_id") or "").strip()
if batch_combo_id:
return f"batch:{batch_combo_id}"
# 没有稳定事件ID时不做推测性去重,避免同一秒内的真实连续送礼被误删。
return ""
@staticmethod
def _gift_merge_key(gift: dict) -> tuple[int, str, str]:
uid = int(gift.get("uid", 0) or 0)
sender_key = str(uid) if uid > 0 else f"name:{gift.get('uname') or '匿名用户'}"
return (
uid,
sender_key,
f"{gift.get('gift_id') or ''}:{gift.get('gift_name') or '礼物'}",
)
def _gift_config(self) -> dict:
cfg = self.config.broadcast_cfg.get("gift_thanks", {})
return cfg if isinstance(cfg, dict) else {}
async def handle_gift(self, gift: dict):
"""记录礼物并在短窗口内合并同一用户的同类礼物后播报感谢。"""
cfg = self._gift_config()
now = time.time()
try:
dedupe_window = max(1.0, float(cfg.get("dedupe_window_sec", 15.0) or 15.0))
except (TypeError, ValueError):
dedupe_window = 15.0
self._seen_gift_events = {
key: seen_at
for key, seen_at in self._seen_gift_events.items()
if now - seen_at <= dedupe_window
}
event_key = self._gift_event_key(gift)
if event_key:
if event_key in self._seen_gift_events:
self.logger.debug(f"[礼物] 忽略重复事件: {event_key}")
return
self._seen_gift_events[event_key] = now
try:
uid = int(gift.get("uid", 0) or 0)
except (TypeError, ValueError):
uid = 0
uname = str(gift.get("uname") or (f"用户{uid}" if uid > 0 else "匿名用户")).strip()
gift_name = str(gift.get("gift_name") or "礼物").strip() or "礼物"
try:
num = max(1, int(gift.get("num", 1) or 1))
except (TypeError, ValueError):
num = 1
normalized = dict(gift)
normalized.update({"uid": uid or None, "uname": uname, "gift_name": gift_name, "num": num, "ts": now})
stats_event_id = event_key or uuid.uuid4().hex
if self.stats_store:
try:
coin_type = str(gift.get("coin_type") or "").strip().casefold()
raw_price = float(gift.get("price", 0) or 0)
raw_total_coin = float(gift.get("total_coin", 0) or 0)
unit_value, total_value = bilibili_gift_cny_values(coin_type, raw_total_coin, num)
self.stats_store.record_gift(
stats_event_id,
platform="bilibili",
platform_user_id=str(uid) if uid > 0 else None,
occurred_at_utc=datetime.fromtimestamp(
float(gift.get("timestamp") or now), timezone.utc
).isoformat(),
gift_id=str(gift.get("gift_id") or "") or None,
gift_name=gift_name,
quantity=num,
unit_value=unit_value,
total_value=total_value,
currency="CNY",
combo_id=str(gift.get("batch_combo_id") or gift.get("combo_id") or "") or None,
payload={
"source_cmd": str(gift.get("source_cmd") or "SEND_GIFT"),
"coin_type": coin_type,
"raw_price": raw_price,
"raw_total_coin": raw_total_coin,
"discount_price": gift.get("discount_price"),
"value_rule": "bilibili_gold_coin_1000_to_cny_1_v2",
},
)
except Exception as e:
self.logger.debug(f"[礼物统计] 写入失败: {e}")
self.recent_gifts.append(normalized)
if len(self.recent_gifts) > self.max_gifts:
self.recent_gifts.pop(0)
if not cfg.get("enabled", True):
return
merge_key = self._gift_merge_key(normalized)
pending = self._pending_gift_thanks.get(merge_key)
if pending:
pending["num"] = int(pending.get("num", 0) or 0) + num
pending["gift"] = normalized
return
try:
max_pending = max(1, int(cfg.get("max_pending", 50) or 50))
except (TypeError, ValueError):
max_pending = 50
if len(self._pending_gift_thanks) >= max_pending:
self.logger.warning(f"[礼物] 待感谢队列已达上限 {max_pending},跳过本次TTS: {uname} {gift_name} x{num}")
return
pending = {"gift": normalized, "num": num}
self._pending_gift_thanks[merge_key] = pending
task = asyncio.create_task(self._flush_gift_thanks(merge_key))
pending["task"] = task
self._gift_tasks.add(task)
task.add_done_callback(self._gift_tasks.discard)
async def _flush_gift_thanks(self, merge_key: tuple[int, str, str]):
try:
cfg = self._gift_config()
try:
delay = max(0.0, min(10.0, float(cfg.get("merge_window_sec", 2.0) or 2.0)))
except (TypeError, ValueError):
delay = 2.0
if delay:
await asyncio.sleep(delay)
pending = self._pending_gift_thanks.pop(merge_key, None)
if not pending:
return
gift = pending["gift"]
values = {
"uname": str(gift.get("uname") or "匿名用户"),
"gift_name": str(gift.get("gift_name") or "礼物"),
"num": max(1, int(pending.get("num", 1) or 1)),
}
template = str(cfg.get("template") or "感谢{uname}送出的{num}{gift_name}")
try:
text = template.format(**values).strip()
except (KeyError, ValueError):
self.logger.warning("[礼物] 感谢文案模板无效,已使用默认模板")
text = "感谢{uname}送出的{num}{gift_name}".format(**values)
if text:
await self.broadcast(
text,
tts=bool(cfg.get("tts", True)),
danmu=bool(cfg.get("danmu", False)),
category="gift",
)
except asyncio.CancelledError:
self._pending_gift_thanks.pop(merge_key, None)
raise
except Exception as e:
self._pending_gift_thanks.pop(merge_key, None)
self.logger.warning(f"[礼物] 感谢播报失败: {e}")
async def close(self):
tasks = list(self._gift_tasks)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._gift_tasks.clear()
self._pending_gift_thanks.clear()
def _get_command_key(self, cmd_text: str) -> str | None:
"""根据配置的别名查找指令 key(如 'queue', 'signin')。
返回 None 表示不是任何内置指令。"""
if not cmd_text:
return None
cmds = self.config.data.get("commands", {})
for key, cfg in cmds.items():
if not cfg.get("enabled", True):
continue
for alias in cfg.get("aliases", []):
if cmd_text == str(alias).strip():
return key
return None
def get_user_role(self, uid: int) -> str:
if uid in self.config.admin_uids:
return ROLE_SUPER_ADMIN
state = self.queue_mgr.state
current_admin = state.get("current_admin_uid")
billing_uid = state.get("billing_uid")
if uid and uid == current_admin:
if state.get("login_status") == "logged_in" or billing_uid == uid:
return ROLE_ACTIVE_OPERATOR
return ROLE_PENDING_OPERATOR
if uid and uid == billing_uid and state.get("current_group"):
return ROLE_ACTIVE_OPERATOR
return ROLE_VIEWER
def _command_allowed_roles(self, command_key: str) -> list[str]:
cfg = self.config.data.get("commands", {}).get(command_key, {})
return normalize_allowed_roles(
cfg.get("allowed_roles"),
COMMAND_ALLOWED_ROLE_DEFAULTS.get(command_key, ALL_DANMU_ROLES),
)
def _effective_queue_admin_uid(self, uid: int) -> int:
if uid in self.config.admin_uids and self.queue_mgr.state.get("current_admin_uid"):
return int(self.queue_mgr.state["current_admin_uid"])
return uid
async def _ensure_user_permitted(self, uid: int, uname: str, *, scope: str, allowed_roles: list[str]) -> bool:
if self.user_mgr.has_block(uid, scope):
if scope == "queue":
await self.broadcast(f"「{uname}」你已被禁止排队,请联系主播", tts=True)
elif scope == "song_request":
await self.broadcast(f"「{uname}」你已被禁止点歌,请联系主播", tts=True)
else:
await self.broadcast(f"「{uname}」你当前没有执行此操作的权限", tts=True)
return False
if self.get_user_role(uid) in allowed_roles:
return True
await self.broadcast(f"「{uname}」你当前没有执行此操作的权限", tts=True)
return False
def _set_pending_group_confirmation(self, uid: int, group_name: str):
self._pending_group_confirmations[int(uid)] = {
"group_name": str(group_name),
"expires_at": time.time() + 60,
}
def _take_pending_group_confirmation(self, uid: int) -> str | None:
pending = self._pending_group_confirmations.pop(int(uid), None)
if not pending or float(pending.get("expires_at", 0)) < time.time():
return None
return str(pending.get("group_name") or "").strip() or None
def _is_custom_rule_command(self, text: str) -> bool:
return any(
isinstance(rule, dict) and self._rule_matches(rule, text)
for rule in self.config.data.get("rules", [])
)
def _is_live_time(self) -> bool:
return is_within_live_time(self.config.system_cfg)
def _rule_matches(self, rule: dict, text: str) -> bool:
keyword = str(rule.get("keyword", "") or "")
if not keyword:
return False
match_type = str(rule.get("match_type", "contains") or "contains").lower()
if match_type == "exact":
return text == keyword
if match_type == "startswith":
return text.startswith(keyword)
if match_type == "regex":
try:
return re.search(keyword, text) is not None
except re.error:
return False
return keyword in text
async def _handle_custom_rules(self, text: str, uid: int, uname: str) -> bool:
rules = self.config.data.get("rules", [])
for index, rule in enumerate(rules):
if not isinstance(rule, dict) or not self._rule_matches(rule, text):
continue
allowed_roles = default_rule_allowed_roles(rule)
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=allowed_roles):
return True
cooldown = max(0, int(rule.get("cooldown", 0) or 0))
last_at = float(self._rule_last_trigger_at.get(index, 0))
if cooldown and time.time() - last_at < cooldown and self.get_user_role(uid) != ROLE_SUPER_ADMIN:
remain = max(1, cooldown - int(time.time() - last_at))
await self.broadcast(f"「{uname}」该指令冷却中,还需等待{remain}秒", tts=True)
return True
groups = [str(item).strip() for item in rule.get("groups", []) if str(item).strip()]
if not groups:
return True
self._rule_last_trigger_at[index] = time.time()
group_name = groups[0]
run_id = uuid.uuid4().hex
# 先收口旧实例,再停止 BetterGI,最后绑定新实例,避免覆盖旧 run_id 后留下 running 记录。
self.queue_mgr.interrupt_group("custom_rule_replaced", status="cancelled")
await self.runner.kill_bgi(reason="自定义规则替换配置组")
self.queue_mgr.state["current_group"] = group_name
self.queue_mgr.state["current_group_run_id"] = run_id
self.queue_mgr.state["group_start_time"] = datetime.now().isoformat()
self.queue_mgr.state["default_running"] = False
self.queue_mgr._save()
if self.stats_store:
self.stats_store.record_group_run(
run_id,
group_name=group_name,
status="running",
payload={"default_running": False, "source": "custom_rule"},
)
self.log_monitor.set_current_group(group_name, run_id)
ok = await self.runner.start_groups(groups)
if ok:
reply = str(rule.get("reply", "") or "").strip()
if reply:
await self.broadcast(reply, tts=True)
else:
await self.broadcast(f"「{uname}」已执行配置组 {group_name}", tts=True)
else:
self.log_monitor.set_current_group(None)
self.queue_mgr.interrupt_group(
"custom_rule_start_failed",
status="failed",
expected_run_id=run_id,
)
await self.broadcast(f"「{uname}」执行配置组失败", tts=True)
return True
return False
async def _try_redeem_code(self, text: str, uid: int, uname: str) -> bool:
"""尝试按完整弹幕兑换;未知文本返回 False,继续走原指令流程。"""
redemption_store = getattr(self, "redemption_store", None)
if redemption_store is None:
return False
balance_before = self.user_mgr.get_points(uid)
try:
result = await redemption_store.reserve(
text,
platform="bilibili",
platform_user_id=str(uid),
display_name=uname,
balance_before=balance_before,
)
except Exception as exc:
self.logger.error(f"[兑换码] 查询或预占失败: {type(exc).__name__}: {exc}")
return False
status = str(result.get("status") or "unknown")
if status == "unknown":
return False
failure_messages = {
"disabled": f"「{uname}」兑换码当前不可用",
"not_started": f"「{uname}」兑换码尚未生效",
"expired": f"「{uname}」兑换码已过期",
"exhausted": f"「{uname}」兑换码已兑完",
"already_redeemed": f"「{uname}」你已经兑换过这个兑换码",
}
if status != "reserved":
message = failure_messages.get(status)
if message:
await self.broadcast(message, tts=False, danmu=True, category="redemption_code")
return True
record_id = int(result["record_id"])
code_id = int(result["code_id"])
points = int(result["points"])
expected_balance = balance_before + points
try:
balance_after = await self.user_mgr.add_points(
uid,
points,
reason="redemption_code",
reference_type="redemption_code",
reference_id=str(code_id),
transaction_id=f"redemption-code:{record_id}",
)
except Exception as exc:
current_balance = self.user_mgr.get_points(uid)
if current_balance == expected_balance:
balance_after = current_balance
self.logger.warning(f"[兑换码] 积分已写入但后续记录异常,继续完成兑换: record={record_id}: {exc}")
else:
try:
await redemption_store.cancel(record_id)
except Exception as cancel_exc:
self.logger.error(f"[兑换码] 取消预占失败: record={record_id}: {cancel_exc}")
self.logger.error(f"[兑换码] 积分写入失败: record={record_id}: {type(exc).__name__}: {exc}")
await self.broadcast(f"「{uname}」兑换失败,请稍后重试", tts=False, danmu=True, category="redemption_code")
return True
try:
await redemption_store.finalize(record_id, balance_after)
except Exception as exc:
# 积分已经到账时保留 pending 记录和占用次数,可阻止重复领取。
self.logger.error(f"[兑换码] 完成记录失败,积分已到账: record={record_id}: {type(exc).__name__}: {exc}")
self.logger.info(
f"[兑换码] {uname}({uid}) 兑换 {result.get('code')} +{points},余额 {balance_after}"
)
await self.broadcast(
f"「{uname}」兑换成功,获得{points}积分,当前余额{balance_after}",
tts=False,
danmu=True,
category="redemption_code",
)
return True
async def handle(self, text: str, uid: int, uname: str,
event_context: dict | None = None):
"""处理一条弹幕;event_context 仅携带平台事件身份和安全元数据。"""
text = (text or "").strip()
if not text:
return
# 先补全身份再建档,避免脱敏昵称覆盖真实昵称。
if uid > 0 and (not uname or "*" in uname):
uname = await self.user_mgr.resolve_uname(uid, uname)
identity_valid = isinstance(uid, int) and not isinstance(uid, bool) and uid > 0
if identity_valid:
self.user_mgr.ensure_user(uid, uname or f"用户{uid}")
else:
self.logger.warning(f"[弹幕身份] 收到匿名/无效 UID,跳过积分和指令业务: {uname}({uid})")
# 匿名消息仍可展示,但 UID 使用 null,避免前端和持久化层误当成账号 0。
self.recent_danmu.append({
"text": text,
"uid": uid if identity_valid else None,
"uname": uname or "匿名用户",
"ts": time.time(),
"identity_valid": identity_valid,
})
if len(self.recent_danmu) > self.max_danmu:
self.recent_danmu.pop(0)
parts = text.split(None, 1)
cmd = parts[0]
if len(parts) == 1:
run_cfg = self.config.data.get("commands", {}).get("run", {})
run_aliases = sorted(
(str(alias).strip() for alias in run_cfg.get("aliases", []) if str(alias).strip()),
key=len,
reverse=True,
)
for alias in run_aliases:
if text.startswith(alias) and len(text) > len(alias):
cmd = alias
break
cmd_key = self._get_command_key(cmd)
is_song_command = self._is_song_request_command(cmd)
is_custom_rule_command = self._is_custom_rule_command(text)
command_name = cmd_key or ("song_request" if is_song_command else "custom_rule" if is_custom_rule_command else None)
context = event_context or {}
if self.stats_store:
try:
self.stats_store.record_danmu(
str(context.get("event_id") or uuid.uuid4().hex),
platform="bilibili",
platform_user_id=str(uid) if identity_valid else None,
room_id=str(context.get("room_id") or self.config.room_id),
message_type="danmu",
content_length=len(text),
command=command_name,
handled=1 if command_name and identity_valid else 0,
payload={
"source": str(context.get("source") or "websocket"),
"identity_valid": identity_valid,
},
)
except Exception as e:
self.logger.debug(f"[弹幕统计] 写入失败: {e}")
if not identity_valid:
return
if await self._try_redeem_code(text, uid, uname):
return
# 队首等待“上号”的窗口从成为队首时固定计算,任意弹幕不得续期。
# 只有真正发送“上号”进入登录阶段后,才由 login_started_at 接管计时。
# 解析指令。执行类指令兼容省略空格,例如“执行薄荷”“跑清心”。
parts = text.split(None, 1) # 按空格分割,最多2段
cmd = parts[0]
arg = parts[1].strip() if len(parts) > 1 else ""
if not arg:
run_cfg = self.config.data.get("commands", {}).get("run", {})
run_aliases = sorted(
(str(alias).strip() for alias in run_cfg.get("aliases", []) if str(alias).strip()),
key=len,
reverse=True,
)
for alias in run_aliases:
if text.startswith(alias) and len(text) > len(alias):
cmd = alias
arg = text[len(alias):].strip()
break
# 用配置别名匹配内置指令。当前指令类别通过 ContextVar 传给所有
# broadcast 调用,因此无需在每个分支重复传 category。
cmd_key = self._get_command_key(cmd)
category_map = {
"queue": "queue",
"signin": "signin",
"login": "login",
"confirm_yes": "login",
"confirm_no": "login",
"run": "execution",
"leave": "queue",
"reset": "reset",
"points": "points",
"queue_list": "queue",
"help": "help",
}
category = category_map.get(cmd_key)
is_song_command = self._is_song_request_command(cmd)
is_custom_rule_command = self._is_custom_rule_command(text)
if not category and is_song_command:
category = "song_request"
is_valid_command = cmd_key is not None or is_song_command or is_custom_rule_command
if is_valid_command and not self._is_live_time():
await self.broadcast("当前未开播哦~", tts=False, danmu=True)
return
category_token = self._tts_category_context.set(category)
try:
await self._dispatch_command(cmd_key, cmd, arg, text, uid, uname)
finally:
self._tts_category_context.reset(category_token)
async def _dispatch_command(
self,
cmd_key: str | None,
cmd: str,
arg: str,
text: str,
uid: int,
uname: str,
):
if cmd_key == "confirm_yes":
suggested_group = self._take_pending_group_confirmation(uid)
if suggested_group:
# 配置组建议确认优先于账号确认“是”,并复用执行指令自身的队首/登录权限校验。
await self._cmd_run(uid, uname, suggested_group)
return
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("confirm_yes")):
return
await self._cmd_confirm_login(uid, uname, "是")
elif cmd_key == "confirm_no":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("confirm_no")):
return
await self._cmd_confirm_login(uid, uname, "不是")
elif cmd_key == "queue":
if not await self._ensure_user_permitted(uid, uname, scope="queue", allowed_roles=self._command_allowed_roles("queue")):
return
await self._cmd_queue(uid, uname)
elif cmd_key == "signin":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("signin")):
return
await self._cmd_signin(uid, uname)
elif cmd_key == "login":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("login")):
return
await self._cmd_login(uid, uname)
elif cmd_key == "run":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("run")):
return
if not arg:
await self.broadcast(
f"「{uname}」请发送“执行 配置组名称”,例如:执行 薄荷",
tts=True,
)
return
await self._cmd_run(uid, uname, arg)
elif cmd_key == "leave":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("leave")):
return
await self._cmd_leave(uid, uname)
elif cmd_key == "reset":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("reset")):
return
await self._cmd_reset(uid, uname)
elif cmd_key == "points":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("points")):
return
await self._cmd_points(uid, uname)
elif cmd_key == "queue_list":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("queue_list")):
return
await self._cmd_queue_list(uid, uname)
elif cmd_key == "help":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("help")):
return
await self._cmd_help(uid, uname)
elif self._is_song_request_command(cmd):
if not await self._ensure_user_permitted(
uid,
uname,
scope="song_request",
allowed_roles=normalize_allowed_roles(self._song_request_cfg().get("allowed_roles"), ALL_DANMU_ROLES),
):
return
await self._cmd_song_request(uid, uname, arg)
elif await self._handle_custom_rules(text, uid, uname):
return
# 非指令弹幕: 已在上面续期窗口,不额外处理
def _song_request_cfg(self) -> dict:
return self.config.music_monitor_cfg.get("request_player", {})
def _is_song_request_command(self, cmd: str) -> bool:
cfg = self._song_request_cfg()
if not cfg.get("enabled", True):
return False
commands = cfg.get("commands", ["点歌", "dg"])
return cmd in set(str(x).strip() for x in commands if str(x).strip())
async def _cmd_song_request(self, uid: int, uname: str, keyword: str):
keyword = (keyword or "").strip()
if not keyword:
await self.broadcast(f"「{uname}」点歌格式: 点歌 歌曲名", tts=True)
return
cfg = self._song_request_cfg()
cost = max(0, int(cfg.get("cost_points", 1)))
points = self.user_mgr.get_points(uid)
if points < cost:
await self.broadcast(f"「{uname}」积分不足,点歌需要{cost}积分", tts=True)
return
if not self.system or not getattr(self.system, "song_request_mgr", None):
await self.broadcast("点歌系统暂未启动", tts=True)
return
song = await self.system.song_request_mgr.search_song(keyword)
if not song:
await self.broadcast(f"「{uname}」没有搜到歌曲: {keyword}", tts=True)
return
add_result = await self.system.song_request_mgr.add_request(uid, uname, song)
if not add_result["success"]:
await self.broadcast(f"「{uname}{add_result['msg']}", tts=True)
return
if cost:
request_id = str(add_result.get("request_id") or song.get("request_id") or "")
await self.user_mgr.add_points(
uid,
-cost,
reason="song_request",
reference_type="song_request",
reference_id=request_id,
)
remain = self.user_mgr.get_points(uid)
await self.broadcast(
f"「{uname}」点歌成功: {song['name']}{cost}分,剩余{remain}",
tts=True
)
if self.queue_mgr.state.get("current_admin_uid") == uid and remain <= 0:
await self.points_sched.handle_current_admin_depleted(uid, remain, "点歌扣分")
async def _cmd_queue(self, uid: int, uname: str):
points = self.user_mgr.get_points(uid)
if points <= 0:
self.logger.info(f"[排队失败] {uname}({uid}) 积分{points}, 不允许排队")
await self.broadcast(f"「{uname}」积分不足,当前{points}分,不能排队,请先签到", tts=True)
return
result = self.queue_mgr.join_queue(uid)
self.logger.info(f"[排队] {uname}({uid}) {result['msg']}")
if result["success"]:
if result["position"] == 1:
await self.broadcast(f"「{uname}」成为队首,发\"上号\"开始", tts=True)
else:
await self.broadcast(f"「{uname}」排第{result['position']}位", tts=True)
async def _cmd_signin(self, uid: int, uname: str):
result = await self.user_mgr.signin(uid)
self.logger.info(f"[签到] {uname}({uid}) {result['msg']}")
if result["success"]:
await self.broadcast(
f"「{uname}」签到成功,当前积分余额{result['points']}",
category="signin",
)
else:
await self.broadcast(
f"「{uname}{result['msg']}",
category="signin",
)
async def _cmd_login(self, uid: int, uname: str):
"""队首发送“上号”:普通情况启动扫码 JS;前一位登录超时后由统一重置接管。"""
effective_uid = self._effective_queue_admin_uid(uid)
# 校验是否队首
if not self.queue_mgr.is_admin(effective_uid):
await self.broadcast(f"「{uname}」你不是队首,请先排队", tts=True)
return
state = self.queue_mgr.state
if state.get("reset_on_next_login_uid") == effective_uid:
self.logger.info(
f"[上号] {uname}({effective_uid}) 接替登录超时用户,执行统一重置流程"
)
state["reset_on_next_login_uid"] = None
self.queue_mgr._save()
await self._perform_reset(
f"「{uname}」开始上号,正在重置原神并启动扫码上号"
)
return
# 如果正在登录或已登录,不重复触发
login_status = self.queue_mgr.state.get("login_status")
if login_status == "logining":
await self.broadcast(f"「{uname}」正在扫码登录中,请等待...", tts=True)
return
if login_status == "confirming":
await self.broadcast(f"「{uname}」请先回复\"\"\"不是\"确认账号", tts=True)
return
if login_status == "logged_in":
await self.broadcast(f"「{uname}」已登录,发送\"执行 组名\"开始", tts=True)
return
# 默认薄荷和扫码启动必须串行。先写入登录状态,再执行耗时的进程关闭,
# 避免薄荷协程在关闭期间通过旧状态检查后与扫码组并发启动。
async with self.system._default_start_lock:
if not self.queue_mgr.is_admin(effective_uid):
result = {"success": False, "msg": "你不是队首"}
elif self.queue_mgr.state.get("login_status") is not None:
result = {"success": False, "msg": "登录状态已变化,请重试"}
else:
self.queue_mgr.interrupt_group("new_admin_login", status="cancelled")
self.queue_mgr.state["default_running"] = False
self.queue_mgr.state["current_group"] = None
self.queue_mgr.state["current_group_run_id"] = None
self.queue_mgr.state["group_start_time"] = None
# 旧用户因积分耗尽出队后,billing_uid 会继续指向旧用户,直到下一位接管。
# 新队首发送“上号”并停止旧 BetterGI 任务时,必须同步终止旧用户计费。
self.queue_mgr.state["billing_started_at"] = None
self.queue_mgr.state["billing_last_at"] = None
self.queue_mgr.state["billing_uid"] = None
self.queue_mgr._save()
result = self.queue_mgr.start_login(effective_uid)
if result["success"]:
self.logger.info("[上号] 启动扫码上号前先强制关闭 BetterGI")
await self.runner.kill_bgi(reason="新队首开始扫码上号")
self.log_monitor.set_current_group(None)
if not result["success"]:
await self.broadcast(f"「{uname}{result['msg']}", tts=True)
return
# 重置登录监控
if self.login_monitor:
self.login_monitor.reset()
# 启动扫码上号配置组
ok = await self.runner.start_groups(["扫码上号"])
if ok:
self.logger.info(f"[上号] {uname}({uid}) 扫码上号已启动,等待登录...")
await self.broadcast(f"「{uname}」扫码上号已启动,请在游戏中扫码")
else:
self.logger.error("[上号] 扫码上号配置组启动失败,保留首次上号时间等待自动过号")
if self.login_monitor:
self.login_monitor.disable()
await self.broadcast(f"「{uname}」扫码上号启动失败,240秒累计计时继续", tts=True)
async def _cmd_confirm_login(self, uid: int, uname: str, answer: str):
"""扫码完成后由队首确认是否为本人账号。"""
effective_uid = self._effective_queue_admin_uid(uid)
if not self.queue_mgr.is_admin(effective_uid):
return
if self.queue_mgr.state.get("login_status") != "confirming":
return
if answer == "是":
self.queue_mgr.set_login_status("logged_in")
self.logger.info(f"[登录确认] {uname}({uid}) 确认账号正确,升为二级")
await self.broadcast(f"「{uname}」账号已确认,发送\"执行 组名\"开始(如: 执行 泡泡桔)")
return
self.logger.info(f"[登录确认] {uname}({uid}) 确认不是本人账号,重新扫码上号")
await self.broadcast(f"「{uname}」账号不正确,正在关闭当前账号并重新扫码上号", tts=True)
self.queue_mgr.set_login_status(None)
if self.login_monitor:
self.login_monitor.disable()
self.login_monitor.clear_status_file()
self.log_monitor.set_current_group(None)
await self.runner.kill_bgi()
await asyncio.sleep(1)
await self._cmd_login(uid, uname)
def _normalize_group_name_for_match(self, name: str) -> str:
"""配置组模糊匹配归一化:去空白/常见分隔符,降低错别字和格式差异影响。"""
name = str(name or "").strip().lower()
table = str.maketrans({
" ": "", "\t": "", "\r": "", "\n": "",
"-": "", "_": "", "—": "", "": "",
"·": "", ".": "", "。": "",
"真": "珍", "珊": "珊",
})
return name.translate(table)
def _resolve_group_name(self, group_name: str) -> dict:
"""把用户输入的配置组名解析为真实文件名。
返回:{"success": bool, "name": str, "matched": bool, "suggestions": list[str], "msg": str}
"""
raw_name = str(group_name or "").strip()
script_dir = Path(self.config.bettergi_work_dir) / "User" / "ScriptGroup"
if not raw_name:
return {"success": False, "msg": "配置组名称为空", "suggestions": []}
if not script_dir.exists():
return {"success": False, "msg": "配置组目录不存在", "suggestions": []}
groups = sorted(p.stem for p in script_dir.glob("*.json") if p.is_file())
if not groups:
return {"success": False, "msg": "没有找到任何配置组", "suggestions": []}
if raw_name in groups:
return {"success": True, "name": raw_name, "matched": False, "suggestions": []}
raw_norm = self._normalize_group_name_for_match(raw_name)
norm_pairs = [(g, self._normalize_group_name_for_match(g)) for g in groups]
for real_name, norm_name in norm_pairs:
if raw_norm and raw_norm == norm_name:
return {"success": True, "name": real_name, "matched": True, "suggestions": []}
contains = [real_name for real_name, norm_name in norm_pairs if raw_norm and (raw_norm in norm_name or norm_name in raw_norm)]
if len(contains) == 1:
return {"success": True, "name": contains[0], "matched": True, "suggestions": []}
scored = []
for real_name, norm_name in norm_pairs:
ratio = difflib.SequenceMatcher(None, raw_norm, norm_name).ratio() if raw_norm and norm_name else 0.0
scored.append((ratio, real_name))
scored.sort(reverse=True)
best_ratio, best_name = scored[0]
if best_ratio >= 0.55:
return {"success": True, "name": best_name, "matched": True, "suggestions": [x[1] for x in scored[:3] if x[0] >= 0.35]}
suggestions = [x[1] for x in scored[:3] if x[0] >= 0.25]
return {"success": False, "msg": f"配置组'{raw_name}'不存在", "suggestions": suggestions}
def _group_uses_nahida_collect(self, group_name: str) -> bool:
"""检查配置组启用的 Pathing 路线中是否包含 nahida_collect 动作。"""
script_group_dir = Path(self.config.bettergi_work_dir) / "User" / "ScriptGroup"
auto_pathing_dir = Path(self.config.bettergi_work_dir) / "User" / "AutoPathing"
group_file = script_group_dir / f"{group_name}.json"
try:
group_data = json.loads(group_file.read_text(encoding="utf-8-sig"))
projects = group_data.get("projects", []) if isinstance(group_data, dict) else []
for project in projects:
if not isinstance(project, dict) or project.get("type") != "Pathing":
continue
if str(project.get("status", "Enabled")) != "Enabled":
continue
folder_name = str(project.get("folderName", "") or "")
route_name = str(project.get("name", "") or "")
if not folder_name or not route_name:
continue
try:
route_file = auto_pathing_dir.joinpath(*Path(folder_name).parts, route_name).resolve()
route_file.relative_to(auto_pathing_dir.resolve())
route_data = json.loads(route_file.read_text(encoding="utf-8-sig"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
self.logger.debug(f"[执行] 跳过无法读取的地图追踪路线: {folder_name}/{route_name}: {exc}")
continue
positions = route_data.get("positions", []) if isinstance(route_data, dict) else []
if any(
isinstance(position, dict)
and str(position.get("action", "")).casefold() == "nahida_collect"
for position in positions
):
return True
except Exception as exc:
self.logger.warning(f"[执行] 检查配置组纳西妲采集标记失败: {group_name}: {exc}")
return False
async def _cmd_run(self, uid: int, uname: str, group_name: str):
"""已登录队首发送"执行 组名": taskkill → 启动指定配置组"""
effective_uid = self._effective_queue_admin_uid(uid)
# 校验配置组是否存在,支持模糊匹配/错别字纠正。
group_result = self._resolve_group_name(group_name)
if not group_result.get("success"):
self.logger.info(
f"[执行失败] {uname}({uid}) {group_result.get('msg', '配置组不存在')}"
)
suggestions = group_result.get("suggestions") or []
if suggestions:
self._set_pending_group_confirmation(uid, suggestions[0])
await self.broadcast(
f"「{uname}{group_result.get('msg')},你是不是想执行: {suggestions[0]}?回复“是”确认",
tts=True
)
else:
await self.broadcast(f"「{uname}{group_result.get('msg', '配置组不存在')}", tts=True)
return
real_group_name = group_result["name"]
if group_result.get("matched"):
self.logger.info(f"[配置组匹配] 用户输入'{group_name}' -> '{real_group_name}'")
group_name = real_group_name
# 校验是否队首
if not self.queue_mgr.is_admin(effective_uid):
await self.broadcast(
f"「{uname}」你不是队首,请先排队", tts=True
)
return
# 校验全部通过后先收口旧实例,再创建新 run_id,避免覆盖旧实例后留下永久 running 记录。
state = self.queue_mgr.state
if state.get("login_status") != "logged_in":
await self.broadcast(f"「{uname}」请先发\"上号\"完成扫码登录", tts=True)
return
if await asyncio.to_thread(self._group_uses_nahida_collect, group_name):
await self.broadcast(
"此配置组需要纳西妲采集,若队伍中无纳西妲可能无法正常运行",
tts=True,
danmu=True,
category="execution",
)
was_running = bool(state["current_group"] or state["default_running"])
if was_running:
self.logger.info(f"[执行] 先停止当前任务,再启动'{group_name}'")
self.queue_mgr.interrupt_group("group_replaced", status="cancelled")
await self.runner.kill_bgi(reason="配置组替换")
run_id = uuid.uuid4().hex
result = self.queue_mgr.start_group(effective_uid, group_name, run_id=run_id)
if not result["success"]:
self.logger.info(f"[执行失败] {uname}({uid}) {result['msg']}")
await self.broadcast(f"「{uname}{result['msg']}", tts=True)
return
# 在启动新 BetterGI 之前先绑定日志边界。旧进程已完成 kill,新实例即使很快结束也不会漏掉完成日志。
self.log_monitor.set_current_group(group_name, run_id)
ok = await self.runner.start_groups([group_name])
if ok:
self.logger.info(
f"[执行] {uname}({uid}) 启动'{group_name}', 每分钟扣{POINTS_PER_MINUTE}积分"
)
await self.broadcast(
f"「{uname}」开始执行配置组'{group_name}',每分钟扣{POINTS_PER_MINUTE}积分"
)
else:
self.log_monitor.set_current_group(None)
self.logger.error(f"[跑组] BetterGI启动失败,进入自动重置并重新扫码流程")
await self.broadcast(f"「{uname}」BetterGI启动失败,系统将自动重置并重新扫码", tts=True)
if self.system:
await self.system._reset_wait_and_retry_login(
effective_uid,
uname,
"配置组启动失败,正在关闭原神并启动扫码上号",
)
else:
await self._perform_reset("配置组启动失败,正在关闭原神并启动扫码上号")
def _is_level_one_user(self, uid: int) -> bool:
"""一级用户:config.json global.admin_uids 中配置的 UID。"""
return uid in self.config.admin_uids
def _is_level_three_only(self, uid: int) -> bool:
"""三级用户:当前队首但尚未确认账号登录。"""
return (
uid == self.queue_mgr.state.get("current_admin_uid")
and self.queue_mgr.state.get("login_status") != "logged_in"
)
async def _cmd_leave(self, uid: int, uname: str):
# 退出仅限一/二/四级用户使用:三级队首不可退出,避免占位后直接跳过上号流程。
if self._is_level_three_only(uid) and not self._is_level_one_user(uid):
await self.broadcast(f"「{uname}」当前为三级队首,不可使用退出指令", tts=True)
return
state = self.queue_mgr.state
running_group = (
uid == state.get("current_admin_uid")
and state.get("current_group") is not None
and not state.get("default_running")
)
if running_group:
result = self.queue_mgr.remove_current_admin_keep_running(
action="leave",
reason="user_requested_while_running",
)
self.logger.info(
f"[退出] {uname}({uid}) 执行中主动退出,已移出队列但保留BGI继续运行"
)
await self.broadcast(
f"「{uname}」退出队列,当前任务继续运行",
tts=True,
)
new_admin = result.get("promoted_uid")
if new_admin:
na = self.user_mgr.users.get(
str(new_admin), {}
).get("uname", "?")
self.logger.info(
f"[顶号] {na}({new_admin}) 成为新队首, "
f"90秒内发\"上号\"开始"
)
await self.broadcast(
f"「{na}」成为新队首,90秒内发送\"上号\"开始"
)
return
result = self.queue_mgr.leave_queue(uid)
self.logger.info(f"[退出] {uname}({uid}) {result['msg']}")
if result["success"]:
await self.broadcast(f"「{uname}」退出排队", tts=True)
if result.get("was_admin"):
new_admin = self.queue_mgr.state.get("current_admin_uid")
if new_admin:
new_uname = self.user_mgr.users.get(str(new_admin), {}).get("uname", "?")
await self.broadcast(
f"「{new_uname}」成为新队首,90秒内发送\"上号\"开始",
tts=True,
)
async def _cmd_reset(self, uid: int, uname: str):
"""一级用户指令:关闭原神和 BetterGI,并启动扫码上号配置组。"""
if not self._is_level_one_user(uid):
await self.broadcast(f"「{uname}」权限不足,重置指令仅限一级用户使用", tts=True)
return
self.logger.info(f"[重置] 一级用户 {uname}({uid}) 请求关闭原神和 BGI,并启动扫码上号")
await self._perform_reset("收到一级重置指令,正在关闭原神和BGI并启动扫码上号")
async def _perform_reset(
self,
message: str | None = None,
preserve_login_started_at: bool = False,
preserved_login_started_at: str | None = None,
):
"""统一重置流程入口:与默认薄荷启动串行化,避免重置期间薄荷并发抢占扫码上号。"""
lock = getattr(self.system, "_default_start_lock", None) if self.system else None
if lock is None:
return await self._perform_reset_locked(
message, preserve_login_started_at, preserved_login_started_at
)
async with lock:
return await self._perform_reset_locked(
message, preserve_login_started_at, preserved_login_started_at
)
async def _perform_reset_locked(
self,
message: str | None = None,
preserve_login_started_at: bool = False,
preserved_login_started_at: str | None = None,
):
"""统一重置流程实现:关闭 BetterGI 和原神,清理状态并为当前队首启动扫码上号。"""
original_login_started_at = preserved_login_started_at
if preserve_login_started_at and not original_login_started_at:
original_login_started_at = self.queue_mgr.state.get("login_started_at")
def restore_login_watchdog(reason: str):
admin_uid = self.queue_mgr.state.get("current_admin_uid")
if not admin_uid or not preserve_login_started_at:
return
fallback_started_at = original_login_started_at or datetime.now().isoformat()
self.queue_mgr.state["login_status"] = "logining"
self.queue_mgr.state["login_started_at"] = fallback_started_at
self.queue_mgr.state["confirm_started_at"] = None
self.queue_mgr.state["admin_window_end"] = None
self.queue_mgr._save()
self.logger.warning(
f"[重置] {reason},已恢复登录计时,240秒累计超时仍继续生效"
)
if message:
await self.broadcast(message, tts=True)
self.queue_mgr.interrupt_group("system_reset", status="cancelled")
await self.runner.kill_bgi(reason="统一重置")
self.log_monitor.set_current_group(None)
self.queue_mgr.state["current_group"] = None
self.queue_mgr.state["current_group_run_id"] = None
self.queue_mgr.state["default_running"] = False
self.queue_mgr.state["group_start_time"] = None
self.queue_mgr.state["login_status"] = None
self.queue_mgr.state["login_started_at"] = None
self.queue_mgr.state["confirm_started_at"] = None
# 重置已经停止旧 BetterGI 任务;无论是新队首接管还是同一用户重试,
# 扫码阶段都不应继续向此前的 billing_uid 扣分。
self.queue_mgr.state["billing_started_at"] = None
self.queue_mgr.state["billing_last_at"] = None
self.queue_mgr.state["billing_uid"] = None
self.queue_mgr._save()
closed = await self._close_genshin()
if not closed:
restore_login_watchdog("关闭原神失败")
return
admin_uid = self.queue_mgr.state.get("current_admin_uid")
if not admin_uid:
await self.broadcast("当前没有队首,无法启动扫码上号", tts=True)
return
login_result = self.queue_mgr.start_login(int(admin_uid))
if not login_result["success"]:
restore_login_watchdog(f"扫码上号状态初始化失败:{login_result['msg']}")
await self.broadcast(f"扫码上号状态初始化失败:{login_result['msg']}", tts=True)
return
if preserve_login_started_at and original_login_started_at:
self.queue_mgr.state["login_started_at"] = original_login_started_at
self.queue_mgr._save()
if self.login_monitor:
self.login_monitor.reset()
ok = await self.runner.start_groups(["扫码上号"])
if ok:
await self.broadcast("扫码上号已启动,请在游戏中扫码", tts=True)
else:
if self.login_monitor:
self.login_monitor.disable()
restore_login_watchdog("BetterGI扫码上号配置组启动失败")
if not preserve_login_started_at:
self.queue_mgr.set_login_status(None)
await self.broadcast("扫码上号启动失败,请检查BetterGI配置组", tts=True)
async def _focus_genshin_window(self) -> bool:
"""把原神窗口置顶/前置,确保扫码上号 JS 能操作正确窗口。"""
if os.name != "nt":
return False
try:
user32 = ctypes.windll.user32
keywords = ["原神", "Genshin Impact", "YuanShen"]
hwnds = []
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
def enum_proc(hwnd, lparam):
try:
if not user32.IsWindowVisible(hwnd):
return True
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
title = buf.value.strip()
title_lower = title.lower()
if any(k.lower() in title_lower for k in keywords):
hwnds.append((hwnd, title))
except Exception:
pass
return True
user32.EnumWindows(EnumWindowsProc(enum_proc), 0)
if not hwnds:
self.logger.warning("[重置] 未找到原神窗口,无法置顶")
return False
hwnd, title = hwnds[0]
user32.ShowWindow(hwnd, 9) # SW_RESTORE
await asyncio.sleep(0.2)
user32.SetForegroundWindow(hwnd)
self.logger.info(f"[重置] 已置顶原神窗口: hwnd={hwnd}, title={title or '-'}")
return True
except Exception as e:
self.logger.warning(f"[重置] 置顶原神窗口失败: {e}")
return False
@staticmethod
def _is_process_running(image_name: str) -> bool:
"""按可执行文件名检查 Windows 进程是否仍在运行。"""
try:
result = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {image_name}", "/FO", "CSV", "/NH"],
capture_output=True,
text=False,
timeout=5,
creationflags=0x08000000,
)
output = (result.stdout or b"").decode("gbk", errors="replace").lower()
return image_name.lower() in output
except Exception:
return False
async def _close_genshin(self) -> bool:
"""关闭国服或国际服原神进程,并确认进程已经退出。"""
image_names = ("YuanShen.exe", "GenshinImpact.exe")
running = [name for name in image_names if self._is_process_running(name)]
if not running:
self.logger.info("[重置] 未发现原神进程,按已关闭处理")
return True
self.logger.info(f"[重置] 准备关闭原神进程: {', '.join(running)}")
for image_name in running:
try:
proc = await asyncio.create_subprocess_exec(
"taskkill", "/F", "/T", "/IM", image_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=0x08000000,
)
stdout, stderr = await proc.communicate()
details = (stdout or stderr or b"").decode("gbk", errors="replace").strip()
if proc.returncode == 0:
self.logger.info(f"[重置] 已发送关闭命令: {image_name}")
else:
self.logger.warning(f"[重置] taskkill {image_name} 返回 {proc.returncode}: {details}")
except Exception as e:
self.logger.warning(f"[重置] 关闭 {image_name} 失败: {e}")
for _ in range(10):
if not any(self._is_process_running(name) for name in image_names):
self.logger.info("[重置] 已确认原神进程退出")
return True
await asyncio.sleep(0.5)
remaining = [name for name in image_names if self._is_process_running(name)]
self.logger.error(f"[重置] 原神进程仍未退出: {', '.join(remaining)}")
await self.broadcast("原神关闭失败,请检查程序权限", tts=True)
return False
async def _restart_genshin(self):
exe = str(self.config.system_cfg.get("genshin_exe", "") or "").strip().strip('"')
if not exe:
self.logger.warning("[重置] 未配置原神 exe 路径,无法重启原神")
await self.broadcast("原神路径未配置,无法重启原神", tts=True)
return
try:
await asyncio.create_subprocess_exec(
"taskkill", "/F", "/IM", "YuanShen.exe",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
creationflags=0x08000000,
)
except Exception as e:
self.logger.debug(f"[重置] taskkill YuanShen.exe: {e}")
await asyncio.sleep(2)
try:
await asyncio.create_subprocess_exec(
exe,
cwd=str(Path(exe).parent),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
creationflags=0x08000000,
)
self.logger.info(f"[重置] 已启动原神: {exe}")
await self.broadcast("原神已重新启动", tts=True)
except Exception as e:
self.logger.error(f"[重置] 启动原神失败: {e}")
await self.broadcast("原神启动失败,请检查路径配置", tts=True)
async def _cmd_points(self, uid: int, uname: str):
points = self.user_mgr.get_points(uid)
self.logger.info(
f"[积分] {uname}({uid}) 当前{points}积分 (上限{MAX_POINTS})"
)
await self.broadcast(f"「{uname}」当前积分余额{points}", tts=True)
async def _cmd_queue_list(self, uid: int, uname: str):
state = self.queue_mgr.state
if not state["queue"]:
self.logger.info("[队列] 当前队列为空")
return
# 构造队列列表
lines = ["[队列] 当前排队:"]
for i, q_uid in enumerate(state["queue"]):
u = self.user_mgr.users.get(str(q_uid), {})
name = u.get("uname", f"用户{q_uid}")
pts = u.get("points", 0)
marker = " [队首]" if q_uid == state["current_admin_uid"] else ""
lines.append(f" {i+1}. {name} ({pts}积分){marker}")
if state.get("login_status") == "logining":
lines.append(" 扫码登录中...")
elif state.get("login_status") == "logged_in":
lines.append(" 已登录,等待执行")
if state["current_group"]:
lines.append(f" 正在运行: {state['current_group']}")
self.logger.info("\n".join(lines))
def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str:
"""获取指令的别名展示字符串,用于帮助信息。"""
cmds = self.config.data.get("commands", {})
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):
q = self._get_cmd_aliases_str("queue", "排队")
s = self._get_cmd_aliases_str("signin", "签到")
l = self._get_cmd_aliases_str("login", "上号")
r = self._get_cmd_aliases_str("run", "执行")
c_yes = self._get_cmd_aliases_str("confirm_yes", "是")
c_no = self._get_cmd_aliases_str("confirm_no", "不是")
leave = self._get_cmd_aliases_str("leave", "退出")
reset = self._get_cmd_aliases_str("reset", "重置")
pts = self._get_cmd_aliases_str("points", "积分")
ql = self._get_cmd_aliases_str("queue_list", "队列")
h = self._get_cmd_aliases_str("help", "帮助")
song_cmd = "/".join(str(x).strip() for x in self._song_request_cfg().get("commands", ["点歌", "dg"]) if str(x).strip())
help_text = (
f"[帮助] 指令列表:\n"
f" {q} - 加入队列\n"
f" {s} - 每日签到随机+{SIGNIN_POINTS_MIN}{SIGNIN_POINTS_MAX}积分(凌晨{SIGNIN_RESET_HOUR}点重置)\n"
f" {l} - 队首触发扫码上号\n"
f" {c_yes}/{c_no} - 确认扫码账号是否正确\n"
f" {r} <组名> - 已确认账号后执行配置组(1积分=1分钟)\n"
f" {song_cmd} <歌名> - 发送点歌\n"
f" {leave} - 退出队列(三级队首不可用)\n"
f" {pts} - 查询积分\n"
f" {ql} - 查看排队\n"
f" {h} - 显示本帮助\n"
f" {reset} - 一级用户重启原神和BetterGI"
)
self.logger.info(help_text)
await self.broadcast(
f"指令: {q}/{s}/{l}/{c_yes}/{c_no}/{r} 组名/{song_cmd} 歌名/{leave}/{reset}/{pts}/{ql}/{h}",
tts=True
)
# ============== B站弹幕协议 ==============
def make_packet(op: int, body: bytes = b"") -> bytes:
if isinstance(body, str):
body = body.encode("utf-8")
total = HEADER_LEN + len(body)
header = struct.pack(">IHHII", total, HEADER_LEN, 1, op, 1)
return header + body
def parse_packets(data: bytes):
offset = 0
packets = []
while offset < len(data):
if offset + HEADER_LEN > len(data):
break
total, header_len, proto_ver, op, seq = struct.unpack(
">IHHII", data[offset:offset + HEADER_LEN]
)
body = data[offset + header_len:offset + total]
packets.append((proto_ver, op, body))
offset += total
return packets
def decode_body(proto_ver: int, body: bytes) -> bytes:
if proto_ver == PROTO_JSON:
return body
if proto_ver == PROTO_ZLIB:
return zlib.decompress(body)
if proto_ver == PROTO_BROTLI:
return brotli.decompress(body)
return body
def _nested_value(data, path):
cur = data
for key in path:
cur = cur.get(key) if isinstance(cur, dict) else None
return cur
def _clean_uid(value) -> int:
"""只接受纯十进制正整数 UID,拒绝星号、空值、布尔值和匿名 0。"""
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value if value > 0 else 0
if isinstance(value, str):
value = value.strip()
return int(value) if value.isdecimal() and int(value) > 0 else 0
return 0
def _read_protobuf_varint(data: bytes, offset: int) -> tuple[int, int]:
value = 0
shift = 0
while offset < len(data) and shift < 70:
current = data[offset]
offset += 1
value |= (current & 0x7F) << shift
if not current & 0x80:
return value, offset
shift += 7
raise ValueError("无效的 Protobuf varint")
def _iter_protobuf_fields(data: bytes):
"""遍历 Protobuf 字段,仅实现 dm_v2 所需线型并安全跳过未知字段。"""
offset = 0
while offset < len(data):
key, offset = _read_protobuf_varint(data, offset)
field_number = key >> 3
wire_type = key & 0x07
if field_number <= 0:
raise ValueError("无效的 Protobuf 字段号")
if wire_type == 0:
value, offset = _read_protobuf_varint(data, offset)
elif wire_type == 1:
end = offset + 8
if end > len(data):
raise ValueError("截断的 Protobuf fixed64")
value = data[offset:end]
offset = end
elif wire_type == 2:
length, offset = _read_protobuf_varint(data, offset)
end = offset + length
if end > len(data):
raise ValueError("截断的 Protobuf bytes")
value = data[offset:end]
offset = end
elif wire_type == 5:
end = offset + 4
if end > len(data):
raise ValueError("截断的 Protobuf fixed32")
value = data[offset:end]
offset = end
else:
raise ValueError(f"不支持的 Protobuf wire type: {wire_type}")
yield field_number, wire_type, value
def _decode_dm_v2(value) -> dict[str, Any]:
"""解析 DANMU_MSG.dm_v2Base64 -> Protobuf Dm/User 最小字段。"""
if not isinstance(value, str) or not value.strip():
return {}
encoded = value.strip()
encoded += "=" * (-len(encoded) % 4)
raw = base64.b64decode(encoded, validate=True)
result: dict[str, Any] = {"text": "", "uid": 0, "uname": ""}
for field_number, wire_type, field_value in _iter_protobuf_fields(raw):
if wire_type != 2:
continue
if field_number == 6:
result["text"] = field_value.decode("utf-8", errors="replace")
elif field_number == 20:
for user_field, user_wire, user_value in _iter_protobuf_fields(field_value):
if user_field == 1 and user_wire == 0:
result["uid"] = _clean_uid(user_value)
elif user_field == 2 and user_wire == 2:
result["uname"] = user_value.decode("utf-8", errors="replace")
return result
def _identity_candidate(uid, uname, source: str) -> dict[str, Any]:
return {
"uid": _clean_uid(uid),
"uname": str(uname or ""),
"source": source,
}
def _select_danmu_identity(candidates: list[dict[str, Any]]) -> tuple[int, str]:
"""只在同一 UID 内补昵称,避免把不同来源的 UID 与昵称错误拼接。"""
valid_uids = [item["uid"] for item in candidates if item["uid"] > 0]
uid = valid_uids[0] if valid_uids else 0
if uid:
for item in candidates:
if item["uid"] == uid and item["uname"] and "*" not in item["uname"]:
return uid, item["uname"]
for item in candidates:
if item["uid"] == uid and item["uname"]:
return uid, item["uname"]
return uid, ""
for item in candidates:
if item["uname"] and "*" not in item["uname"]:
return 0, item["uname"]
for item in candidates:
if item["uname"]:
return 0, item["uname"]
return 0, ""
def extract_danmu(body: bytes):
results = []
try:
msg = json.loads(body.decode("utf-8", errors="replace"))
except Exception:
return results
cmd = msg.get("cmd", "")
if not cmd.startswith("DANMU_MSG"):
return results
info = msg.get("info", [])
if not isinstance(info, list):
info = []
text = str(info[1] if len(info) > 1 and info[1] is not None else "")
member = info[2] if len(info) > 2 else None
info_uid = 0
info_uname = ""
if isinstance(member, (list, tuple)):
info_uid = _clean_uid(member[0] if len(member) > 0 else 0)
info_uname = str(member[1] if len(member) > 1 and member[1] is not None else "")
elif isinstance(member, dict):
info_uid = _clean_uid(member.get("uid_str") or member.get("uid") or member.get("mid"))
info_uname = str(member.get("uname") or member.get("name") or "")
# extra 是传统 info 的增强字段;只把同一个 extra 中的 UID 与昵称组成候选身份。
extra = None
try:
meta = info[0] if info and isinstance(info[0], (list, tuple)) else []
extra = meta[15] if len(meta) > 15 else None
if isinstance(extra, str) and extra:
extra = json.loads(extra)
except Exception:
extra = None
extra_uid = 0
extra_uname = ""
if isinstance(extra, dict):
for path in (("user", "base", "uid"), ("user", "uid"), ("user_info", "uid"), ("member", "uid")):
extra_uid = _clean_uid(_nested_value(extra, path))
if extra_uid:
break
for path in (("user", "base", "origin_info", "name"), ("user", "base", "name"), ("user", "uname"), ("user_info", "uname"), ("member", "uname")):
candidate = _nested_value(extra, path)
if isinstance(candidate, str) and candidate:
extra_uname = candidate
if "*" not in candidate:
break
# 新版弹幕将完整字段放在 dm_v2:标准 Base64 解码后是 Protobuf Dm。
# 解析失败必须静默回退,不能影响传统 info/extra 弹幕。
dm_v2 = {}
try:
dm_v2 = _decode_dm_v2(msg.get("dm_v2"))
except Exception:
dm_v2 = {}
if dm_v2.get("text"):
text = str(dm_v2["text"])
candidates = [
_identity_candidate(dm_v2.get("uid"), dm_v2.get("uname"), "dm_v2"),
_identity_candidate(info_uid, info_uname, "info"),
_identity_candidate(extra_uid, extra_uname, "extra"),
]
uid, uname = _select_danmu_identity(candidates)
# 昵称脱敏补全统一由异步 CommandHandler.handle -> UserManager.resolve_uname 处理,
# 此处保持纯解析,避免在事件循环里做同步网络请求阻塞弹幕处理。
if text:
results.append((text, uid, uname))
return results
def extract_gift(body: bytes) -> dict | None:
"""解析 B 站普通送礼事件。COMBO_SEND 仅用于展示累计值,首版不处理以避免重复感谢。"""
try:
msg = json.loads(body.decode("utf-8", errors="replace"))
except Exception:
return None
if not isinstance(msg, dict):
return None
cmd = str(msg.get("cmd") or "").split(":", 1)[0]
if cmd != "SEND_GIFT":
return None
data = msg.get("data")
if not isinstance(data, dict):
return None
uid = _clean_uid(data.get("uid") or _nested_value(data, ("sender_uinfo", "uid")))
uname = str(
data.get("uname")
or _nested_value(data, ("sender_uinfo", "base", "name"))
or _nested_value(data, ("sender_uinfo", "base", "origin_info", "name"))
or ""
).strip()
gift_name = str(data.get("giftName") or data.get("gift_name") or "礼物").strip() or "礼物"
gift_id = str(data.get("giftId") or data.get("gift_id") or "").strip()
try:
num = max(1, int(data.get("num", 1) or 1))
except (TypeError, ValueError):
num = 1
try:
timestamp = int(data.get("timestamp") or msg.get("timestamp") or time.time())
except (TypeError, ValueError):
timestamp = int(time.time())
try:
price = int(data.get("price", 0) or 0)
except (TypeError, ValueError):
price = 0
try:
total_coin = int(data.get("total_coin", 0) or 0)
except (TypeError, ValueError):
total_coin = 0
try:
discount_price = int(data.get("discount_price", 0) or 0)
except (TypeError, ValueError):
discount_price = 0
return {
"event_type": "gift",
"source_cmd": cmd,
"uid": uid,
"uname": uname,
"gift_id": gift_id,
"gift_name": gift_name,
"num": num,
"action": str(data.get("action") or "").strip(),
"coin_type": str(data.get("coin_type") or "").strip(),
"price": price,
"total_coin": total_coin,
"discount_price": discount_price,
"timestamp": timestamp,
"transaction_id": str(data.get("tid") or data.get("transaction_id") or "").strip(),
"batch_combo_id": str(data.get("batch_combo_id") or "").strip(),
"combo_id": str(data.get("combo_id") or "").strip(),
}
def _request_bilibili_json(url: str, cookie: str = "", referer: str = "") -> dict:
headers = {"User-Agent": "Mozilla/5.0"}
if cookie:
headers["Cookie"] = cookie
if referer:
headers["Referer"] = referer
headers["Origin"] = "https://live.bilibili.com"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def get_real_room_id(room_id: int, cookie: str = "") -> int:
"""短号转换为弹幕认证需要的真实 room_id。"""
data = _request_bilibili_json(
f"https://api.live.bilibili.com/room/v1/Room/room_init?id={int(room_id)}",
cookie,
f"https://live.bilibili.com/{int(room_id)}",
)
if data.get("code") != 0:
raise RuntimeError(f"room_init失败: code={data.get('code')} message={data.get('message', '')}")
real_room_id = _clean_uid((data.get("data") or {}).get("room_id"))
if not real_room_id:
raise RuntimeError("room_init未返回有效真实房间号")
return real_room_id
def get_danmu_server(room_id: int, cookie: str = "") -> dict:
"""使用登录 Cookie 获取弹幕服务器和专用 tokentoken 不得回退为 SESSDATA。"""
url = f"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={int(room_id)}"
response = _request_bilibili_json(url, cookie, f"https://live.bilibili.com/{int(room_id)}")
if response.get("code") == -352:
# 部分网络环境下新接口要求 WBI 签名;旧 getConf 仍可返回专用 token。
# 这里只回退 token 接口,WebSocket 登录 Cookie、真实 UID、buvid 和真实房间号仍完整保留。
fallback_url = f"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={int(room_id)}"
response = _request_bilibili_json(fallback_url, cookie, f"https://live.bilibili.com/{int(room_id)}")
if response.get("code") != 0:
raise RuntimeError(f"获取弹幕token失败: code={response.get('code')} message={response.get('message', '')}")
data = response.get("data") or {}
token = str(data.get("token", "") or "")
if not token:
raise RuntimeError("弹幕接口未返回认证 token")
host_list = data.get("host_list", []) or data.get("host_server_list", []) or []
if not host_list:
raise RuntimeError("getDanmuInfo未返回弹幕服务器")
entry = host_list[0]
host = entry["host"]
port = entry.get("wss_port", 443)
return {"ws_url": f"wss://{host}:{port}/sub", "token": token}
def get_buvid3(cookie: str = "") -> str:
"""优先复用 Cookie 中的 buvid3,否则通过 finger/spi 获取。"""
if cookie:
try:
parsed = http.cookies.SimpleCookie()
parsed.load(cookie.replace("; ", ";"))
if "buvid3" in parsed and parsed["buvid3"].value:
return parsed["buvid3"].value
except Exception:
pass
try:
data = _request_bilibili_json("https://api.bilibili.com/x/frontend/finger/spi", cookie)
if data.get("code") == 0:
return str((data.get("data") or {}).get("b_3", "") or "")
except Exception:
pass
return ""
def get_real_uid(cookie: str = "") -> int:
"""验证登录态并获取建立弹幕连接的 B 站账号 UID。"""
if not cookie:
return 0
try:
response = _request_bilibili_json("https://api.bilibili.com/x/web-interface/nav", cookie)
data = response.get("data") or {}
if response.get("code") == 0 and data.get("isLogin"):
return _clean_uid(data.get("mid"))
except Exception:
pass
return 0
# ============== 弹幕客户端 ==============
class BliveClient:
def __init__(self, room_id: int, cookie: str,
handler: CommandHandler, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.room_id = int(room_id)
self.cookie = cookie
self.handler = handler
self.logger = logger
self.stats_store = stats_store
self._stop = False
self._connection_attempts = 0
self._ws = None
self._auth_dirty = True
self._buvid3 = ""
self._real_uid = 0
self._real_room_id = int(room_id)
self._masked_identity_count = 0
self._last_identity_reconnect_at = 0.0
def apply_config(self, room_id: int, cookie: str):
changed = int(room_id) != self.room_id or cookie != self.cookie
self.room_id = int(room_id)
self.cookie = cookie
if changed:
self._auth_dirty = True
self.logger.info(f"[直播监听] 配置已更新,准备重连 room_id={self.room_id}")
if self._ws is not None:
asyncio.create_task(self._ws.close())
def _refresh_identity(self):
self._real_room_id = get_real_room_id(self.room_id, self.cookie)
self._buvid3 = get_buvid3(self.cookie)
self._real_uid = get_real_uid(self.cookie)
self._auth_dirty = False
async def run(self):
# 完整登录认证能避免连接运行数分钟后退化为 uid=0 + 星号昵称。
await asyncio.to_thread(self._refresh_identity)
if self._buvid3:
self.logger.info("已获得弹幕认证 buvid3")
else:
self.logger.warning("未获得 buvid3,弹幕连接可能被限流或脱敏")
if self._real_uid:
self.logger.info(f"B站登录态有效,弹幕认证UID: {self._real_uid}")
else:
self.logger.error("B站登录态无效:无法获取发送者真实 UID/昵称,请更新 SESSDATA(建议同时配置完整 cookie/buvid3")
self._auth_dirty = False
retry = 0
while not self._stop:
try:
if self._auth_dirty:
await asyncio.to_thread(self._refresh_identity)
server = get_danmu_server(self._real_room_id, self.cookie)
await self._connect_once(server["ws_url"], server["token"])
retry = 0
except asyncio.CancelledError:
break
except Exception as e:
self.logger.warning(f"连接断开: {e}")
if self._stop:
break
retry += 1
wait = min(2 ** retry, 60)
self.logger.info(f"{wait}秒后重连(第{retry}次)...")
await asyncio.sleep(wait)
def stop(self):
self._stop = True
async def _connect_once(self, ws_url: str, token: str):
connection_id = uuid.uuid4().hex
reconnect_count = 0 if self._connection_attempts == 0 else 1
self._connection_attempts += 1
disconnect_reason = "remote_closed"
connected_at_utc = datetime.now(timezone.utc).isoformat()
self.logger.info(f"连接直播间 room_id={self._real_room_id} ...")
if self.stats_store:
self.stats_store.record_bilibili_connection(
connection_id,
room_id=str(self._real_room_id),
connected_at_utc=connected_at_utc,
status="connecting",
reconnect_count=reconnect_count,
payload={"platform": "bilibili", "transport": "websocket"},
)
headers = {"User-Agent": "Mozilla/5.0", "Origin": "https://live.bilibili.com"}
if self.cookie:
headers["Cookie"] = self.cookie
try:
ws = await websockets.connect(
ws_url, extra_headers=headers, max_size=None,
ping_interval=None, ping_timeout=None, close_timeout=5,
)
except Exception:
if self.stats_store:
self.stats_store.record_bilibili_connection(
connection_id,
room_id=str(self._real_room_id),
connected_at_utc=connected_at_utc,
disconnected_at_utc=datetime.now(timezone.utc).isoformat(),
status="failed",
reconnect_count=reconnect_count,
disconnect_reason="exception",
payload={"platform": "bilibili", "phase": "handshake"},
)
raise
if ws is not None:
self._ws = ws
hb_task = None
# DEBUG 计数:用于排查弹幕接收链路
cnt_raw = 0
cnt_packet = 0
cnt_op_message = 0
cnt_danmu = 0
cnt_gift = 0
last_debug_ts = time.time()
try:
auth = {
"uid": self._real_uid or 0, "roomid": self._real_room_id,
"protover": PROTO_BROTLI, "platform": "web", "type": 2,
"key": token,
}
if self._buvid3:
auth["buvid"] = self._buvid3
await ws.send(make_packet(OP_AUTH, json.dumps(auth)))
# 立即发送一个心跳,激活弹幕推送
await ws.send(make_packet(OP_HEARTBEAT))
async def heartbeat():
while True:
await asyncio.sleep(15)
try:
await ws.send(make_packet(OP_HEARTBEAT))
except Exception:
break
hb_task = asyncio.create_task(heartbeat())
async for raw in ws:
if isinstance(raw, str):
continue
cnt_raw += 1
for proto_ver, op, body in parse_packets(raw):
cnt_packet += 1
if op == OP_AUTH_REPLY:
try:
resp = json.loads(body.decode("utf-8", errors="replace"))
if resp.get("code") == 0:
self.logger.info("认证成功,开始监听弹幕")
if self.stats_store:
self.stats_store.record_bilibili_connection(
connection_id,
room_id=str(self._real_room_id),
connected_at_utc=connected_at_utc,
status="authenticated",
reconnect_count=reconnect_count,
payload={"platform": "bilibili", "auth": "success"},
)
else:
disconnect_reason = "auth_failed"
self.logger.error(f"认证失败: {resp}")
await ws.close(code=1008, reason="auth failed")
raise RuntimeError("Bilibili弹幕鉴权失败")
except RuntimeError:
raise
except Exception as e:
disconnect_reason = "auth_failed"
raise RuntimeError(f"Bilibili鉴权响应无效: {e}") from e
elif op == OP_HEARTBEAT_REPLY:
if len(body) >= 4:
pop = struct.unpack(">I", body[:4])[0]
self.logger.debug(f"人气值: {pop}")
elif op == OP_MESSAGE:
cnt_op_message += 1
try:
decoded = decode_body(proto_ver, body)
except Exception as e:
self.logger.debug(f"解压失败 proto={proto_ver}: {e}")
continue
for sub_proto, sub_op, sub_body in parse_packets(decoded):
if sub_op == OP_MESSAGE:
for text, uid, uname in extract_danmu(sub_body):
cnt_danmu += 1
masked_identity = uid <= 0 or not uname or "*" in uname
if masked_identity:
self._masked_identity_count += 1
else:
self._masked_identity_count = 0
self.logger.info(f"[弹幕] {uname}({uid}): {text}")
await self.handler.handle(
text,
uid,
uname,
event_context={
"event_id": uuid.uuid4().hex,
"room_id": self._real_room_id,
"source": "bilibili_websocket",
},
)
# 连续脱敏说明当前连接认证可能已退化。先展示消息,随后刷新
# room_id/buvid/登录 UID 并重连,避免永久停留在 B***(0)。
now = time.time()
if (
self._masked_identity_count >= 2
and now - self._last_identity_reconnect_at >= 30
):
self._auth_dirty = True
self._last_identity_reconnect_at = now
self._masked_identity_count = 0
self.logger.warning(
"[弹幕身份] 连续收到脱敏身份,刷新登录认证并重连"
)
disconnect_reason = "identity_degraded"
await ws.close(code=1012, reason="refresh bilibili identity")
break
try:
gift = extract_gift(sub_body)
if gift:
cnt_gift += 1
self.logger.info(
f"[礼物] {gift.get('uname')}({gift.get('uid')}): "
f"{gift.get('gift_name')} x{gift.get('num')}"
)
await self.handler.handle_gift(gift)
except Exception as e:
self.logger.warning(f"[礼物] 单条事件处理失败,已跳过: {e}")
# 每 60 秒输出一次 DEBUG 统计
now = time.time()
if now - last_debug_ts >= 60:
self.logger.debug(
f"[弹幕统计] 收到{cnt_raw}个raw | "
f"解析{cnt_packet}个包 | "
f"OP_MESSAGE={cnt_op_message} | "
f"提取弹幕={cnt_danmu} | "
f"提取礼物={cnt_gift}"
)
last_debug_ts = now
except asyncio.CancelledError:
disconnect_reason = "shutdown" if self._stop else "exception"
raise
except Exception:
if disconnect_reason not in {"auth_failed", "identity_degraded"}:
disconnect_reason = "exception"
raise
finally:
if hb_task is not None:
hb_task.cancel()
await asyncio.gather(hb_task, return_exceptions=True)
try:
if not ws.closed:
await ws.close()
except Exception as close_error:
self.logger.debug(f"[直播监听] 关闭 WebSocket 失败: {close_error}")
self._ws = None
if self._stop:
disconnect_reason = "shutdown"
if self.stats_store:
final_status = (
"failed"
if disconnect_reason in {"auth_failed", "exception"}
else "disconnected"
)
self.stats_store.record_bilibili_connection(
connection_id,
room_id=str(self._real_room_id),
connected_at_utc=connected_at_utc,
disconnected_at_utc=datetime.now(timezone.utc).isoformat(),
status=final_status,
reconnect_count=reconnect_count,
disconnect_reason=disconnect_reason,
payload={"platform": "bilibili"},
)
async def run_client_forever(client: BliveClient, logger: logging.Logger, health: ServiceRegistry | None = None):
"""直播间监听主循环兜底:如果 client.run 意外返回,自动重启监听。"""
name = "直播监听"
while True:
try:
if health:
health.set(name, ServiceRegistry.STARTING, f"room_id={client.room_id}")
await client.run()
logger.warning("[直播监听] client.run 已返回,3秒后重新启动")
if health:
health.set(name, ServiceRegistry.RECONNECTING, "client.run returned")
except asyncio.CancelledError:
logger.info("[直播监听] 已取消")
if health:
health.set(name, ServiceRegistry.STOPPED, "cancelled")
raise
except Exception as e:
logger.error(f"[直播监听] 异常退出: {e}\n{''.join(traceback.format_exception(type(e), e, e.__traceback__))}")
if health:
health.set(name, ServiceRegistry.RECONNECTING, "exception, reconnecting", str(e))
health.bump_restart(name)
await asyncio.sleep(3)
# ============== 主控制器 ==============
class SystemScheduler:
"""直播时间调度:开播前准备、开关推流、关播前清理。"""
STARTUP_LAUNCH_DELAY_SECONDS = 60
STARTUP_PUSH_DELAY_SECONDS = 60
def __init__(self, config: Config, logger: logging.Logger, system: "QueueSystem" | None = None):
self.config = config
self.logger = logger
self.system = system
self._stop = False
self._last_reboot_date = ""
self._triggered_events: set[str] = set()
self._live_session_id: str | None = None
self._live_session_started_at: datetime | None = None
self._startup_compensation_task: asyncio.Task | None = None
self._startup_compensation_window_key: str | None = None
@property
def cfg(self) -> dict:
return self.config.system_cfg
def _is_enabled(self, key: str) -> bool:
return bool(self.cfg.get(key, False))
@staticmethod
def _valid_hhmm(hhmm: str) -> bool:
value = str(hhmm or "").strip()
if not re.fullmatch(r"\d{2}:\d{2}", value):
return False
try:
datetime.strptime(value, "%H:%M")
return True
except ValueError:
return False
def _match_time(self, hhmm: str) -> bool:
hhmm = str(hhmm or "").strip()
if not self._valid_hhmm(hhmm):
return False
return datetime.now().strftime("%H:%M") == hhmm
def is_live_now(self, now: datetime | None = None) -> bool:
return is_within_live_time(self.cfg, now=now)
def _event_key(self, event: str, when: datetime) -> str:
return f"{event}:{when.strftime('%Y-%m-%dT%H:%M')}"
def _current_live_window(self, now: datetime | None = None) -> tuple[datetime, datetime] | None:
current = now or datetime.now()
for event, start_at in self._live_occurrences(current):
if event != "start":
continue
end_at = next((when for name, when in self._live_occurrences(start_at) if name == "stop" and when > start_at), None)
if end_at and start_at <= current < end_at:
return start_at, end_at
return None
def _begin_live_statistics(self, start_at: datetime, *, reason: str) -> None:
stats_store = self.system.stats_store if self.system else None
if not stats_store:
return
session_id = f"scheduled-live:{start_at.strftime('%Y%m%dT%H%M')}"
if self._live_session_id == session_id:
return
start_utc = start_at.astimezone().astimezone(timezone.utc)
self._live_session_id = session_id
self._live_session_started_at = start_utc
stats_store.begin_live_session(
session_id,
kind="live",
started_at_utc=start_utc.isoformat(),
source="system_scheduler",
payload={"reason": reason, "scheduled_start_local": start_at.isoformat()},
)
def _refresh_live_statistics(self, now: datetime | None = None) -> None:
stats_store = self.system.stats_store if self.system else None
if not stats_store or not self._live_session_id:
return
snapshot_utc = (now or datetime.now()).astimezone().astimezone(timezone.utc)
stats_store.end_live_session(
self._live_session_id,
ended_at_utc=snapshot_utc.isoformat(),
status="running",
payload={"snapshot": True},
)
def _end_live_statistics(self, end_at: datetime | None = None, *, reason: str) -> None:
stats_store = self.system.stats_store if self.system else None
if not stats_store or not self._live_session_id:
return
ended_utc = (end_at or datetime.now()).astimezone().astimezone(timezone.utc)
if self._live_session_started_at and ended_utc < self._live_session_started_at:
ended_utc = self._live_session_started_at
stats_store.end_live_session(
self._live_session_id,
ended_at_utc=ended_utc.isoformat(),
status="ended",
payload={"reason": reason},
)
self._live_session_id = None
self._live_session_started_at = None
def _live_occurrences(self, now: datetime) -> list[tuple[str, datetime]]:
start_minute = _parse_hhmm_minutes(self.cfg.get("live_start_time", ""))
end_minute = _parse_hhmm_minutes(self.cfg.get("live_end_time", ""))
if start_minute is None or end_minute is None or start_minute == end_minute:
return []
occurrences: list[tuple[str, datetime]] = []
# 同时构造今天与明天的场次,才能覆盖00:05开播对应前一天23:55准备等跨日边界。
for start_date in (now.date() - timedelta(days=1), now.date(), now.date() + timedelta(days=1)):
start_at = datetime.combine(start_date, datetime.min.time()).replace(
hour=start_minute // 60, minute=start_minute % 60
)
end_date = start_date if end_minute > start_minute else start_date + timedelta(days=1)
end_at = datetime.combine(end_date, datetime.min.time()).replace(
hour=end_minute // 60, minute=end_minute % 60
)
occurrences.extend([
("prepare_tts", start_at - timedelta(minutes=15)),
("prepare", start_at - timedelta(minutes=10)),
("start", start_at),
("cleanup", end_at - timedelta(minutes=1)),
("stop", end_at),
])
return occurrences
@staticmethod
def _event_due(now: datetime, when: datetime, grace_seconds: int = 70) -> bool:
delta = (now - when).total_seconds()
return 0 <= delta < grace_seconds
async def _cleanup_before_stop(self):
if not self.system:
self.logger.warning("[直播时间] 未绑定系统实例,无法关闭原神和BetterGI")
return
self.logger.info("[直播时间] 距离关播1分钟,关闭BetterGI和原神")
self.system.queue_mgr.interrupt_group("scheduled_shutdown", status="cancelled")
await self.system.runner.kill_bgi(reason="关播前1分钟自动停止")
self.system.log_monitor.set_current_group(None)
self.system.queue_mgr.state["current_group"] = None
self.system.queue_mgr.state["current_group_run_id"] = None
self.system.queue_mgr.state["group_start_time"] = None
self.system.queue_mgr.state["default_running"] = False
self.system.queue_mgr.state["billing_started_at"] = None
self.system.queue_mgr.state["billing_last_at"] = None
self.system.queue_mgr.state["billing_uid"] = None
self.system.queue_mgr._save()
await self.system.handler._close_genshin()
def ensure_startup_shortcut(self):
"""创建/删除当前 run.bat 的开机自启项。使用 HKCU Run,避免非 ASCII 路径脚本编码问题。"""
if os.name != "nt":
return
bat_name = str(self.cfg.get("startup_bat", "run.bat") or "run.bat")
bat_path = project_path(bat_name)
run_key = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
value_name = "BetterGI弹幕排队系统"
if not self._is_enabled("enable_startup_shortcut"):
try:
subprocess.run(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
f"Remove-ItemProperty -Path '{run_key}' -Name '{value_name}' -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self.logger.info("[系统定时] 已关闭开机自启")
except Exception as e:
self.logger.warning(f"[系统定时] 删除开机自启失败: {e}")
return
if not bat_path.exists():
self.logger.warning(f"[系统定时] 开机自启 bat 不存在: {bat_path}")
return
# /k 明确保留交互式控制台,避免登录阶段由 cmd /c start 间接拉起后只有后台进程、没有可见窗口。
command = f'cmd.exe /d /k ""{bat_path}""'
# 值直接内联进脚本(PowerShell -Command 的尾随参数不会进入 $args,不能用它传参)。
# 单引号字符串只需把内容中的单引号 doubling 转义;Python 到 PowerShell 不存在编码问题。
def _ps_quote(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
ps = (
f"New-Item -Path {_ps_quote(run_key)} -Force | Out-Null; "
f"Set-ItemProperty -Path {_ps_quote(run_key)} -Name {_ps_quote(value_name)} -Value {_ps_quote(command)}"
)
try:
result = subprocess.run(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if result.returncode != 0:
message = (result.stderr or result.stdout or f"exit={result.returncode}").strip()
raise RuntimeError(message)
self.logger.info(f"[系统定时] 开机自启已启用(显示控制台): {command}")
except Exception as e:
self.logger.warning(f"[系统定时] 创建开机自启失败: {e}")
def _find_window_by_keyword(self, keyword: str):
"""按窗口标题关键字查找顶层可见窗口,返回 hwnd。"""
if os.name != "nt":
return None
keyword = str(keyword or "").strip().lower()
if not keyword:
return None
user32 = ctypes.windll.user32
hwnd_found = []
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
def callback(hwnd, lparam):
try:
if not user32.IsWindowVisible(hwnd):
return True
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
title = buf.value.strip()
if keyword in title.lower():
hwnd_found.append(hwnd)
return False
except Exception:
return True
return True
user32.EnumWindows(EnumWindowsProc(callback), 0)
return hwnd_found[0] if hwnd_found else None
async def _click_bilibili_live(
self,
*,
x_ratio_key: str,
y_ratio_key: str,
action_name: str,
confirm_enter: bool = False,
) -> bool:
"""把直播姬窗口前置,在指定比例坐标点击;可选按 Enter 确认弹窗。"""
if os.name != "nt":
self.logger.warning(f"[系统定时] 当前系统不支持自动{action_name}")
return False
keyword = self.cfg.get("bilibili_push_window_keyword", "直播姬")
hwnd = self._find_window_by_keyword(keyword)
if not hwnd:
self.logger.warning(f"[系统定时] 未找到直播姬窗口,关键字: {keyword}")
return False
user32 = ctypes.windll.user32
rect = ctypes.wintypes.RECT()
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:
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
await asyncio.sleep(0.5)
user32.SetCursorPos(x, y)
await asyncio.sleep(0.1)
user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN
await asyncio.sleep(0.05)
user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP
if confirm_enter:
await asyncio.sleep(0.8)
user32.keybd_event(0x0D, 0, 0, 0) # VK_RETURN key down
user32.keybd_event(0x0D, 0, 0x0002, 0) # key up
self.logger.info(f"[系统定时] 已点击直播姬{action_name}: hwnd={hwnd}, x={x}, y={y}")
return True
except Exception as e:
self.logger.warning(f"[系统定时] 点击直播姬{action_name}失败: {e}")
return False
async def push_bilibili_live(self) -> bool:
return await self._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮",
)
async def stop_bilibili_live(self) -> bool:
return await self._click_bilibili_live(
x_ratio_key="bilibili_stop_push_click_x_ratio",
y_ratio_key="bilibili_stop_push_click_y_ratio",
action_name="关闭推流按钮",
confirm_enter=bool(self.cfg.get("bilibili_stop_push_confirm_enter", True)),
)
def _schedule_reboot_after_stop(self, stopped_at: datetime) -> bool:
if not self._is_enabled("reboot_after_stop_enabled"):
return False
reboot_date = stopped_at.date().isoformat()
if self._last_reboot_date == reboot_date:
self.logger.info("[直播时间] 今日关播后的自动重启已安排,跳过重复请求")
return False
if os.name != "nt":
self.logger.warning("[直播时间] 当前系统不支持关播后自动重启")
return False
try:
delay_sec = int(self.cfg.get("reboot_after_stop_delay_sec", 60))
except (TypeError, ValueError):
delay_sec = 60
delay_sec = max(1, min(delay_sec, 86400))
try:
subprocess.Popen(
[
"shutdown", "/r", "/t", str(delay_sec), "/c",
"BetterGI直播系统关播后自动重启",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self._last_reboot_date = reboot_date
self.logger.warning(f"[直播时间] 推流已关闭,{delay_sec}秒后重启电脑")
return True
except Exception as e:
self.logger.error(f"[直播时间] 安排关播后自动重启失败: {e}")
return False
def _is_exe_running(self, exe_key: str) -> bool:
if os.name != "nt":
return False
exe = str(self.cfg.get(exe_key, "") or "").strip().strip('"')
image_name = Path(exe).name
if not image_name:
return False
try:
result = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {image_name}", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return image_name.lower() in (result.stdout or "").lower()
except Exception:
return False
async def _launch_exe(self, name: str, exe_key: str) -> bool:
exe = str(self.cfg.get(exe_key, "") or "").strip().strip('"')
if not exe:
self.logger.warning(f"[系统定时] {name} 启动路径为空,请在后台配置")
return False
path = Path(exe)
if not path.exists():
self.logger.warning(f"[系统定时] {name} 启动路径不存在: {path}")
return False
if self._is_exe_running(exe_key):
self.logger.info(f"[系统定时] {name} 已在运行,跳过重复启动")
return False
try:
subprocess.Popen(
[str(path)], cwd=str(path.parent),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
self.logger.info(f"[系统定时] 已启动{name}: {path}")
return True
except Exception as e:
self.logger.warning(f"[系统定时] 启动{name}失败: {e}")
return False
def _startup_window_active(self, start_at: datetime, end_at: datetime) -> bool:
now = datetime.now()
return not self._stop and start_at <= now < end_at and self.is_live_now(now)
async def _restart_tts_worker_before_live(self):
"""开播前重建 TTS workerworker 长运行(尤其隔夜)后合成耗时会严重退化,
趁开播前观众少时主动重建,避免直播初期性能悬崖。"""
broadcaster = getattr(self.system, "broadcaster", None) if self.system else None
tts = getattr(broadcaster, "tts", None) if broadcaster else None
if not tts or not getattr(tts, "enabled", False):
return
if not bool(tts.config.broadcast_cfg.get("tts_queue", {}).get("rebuild_before_live", True)):
return
self.logger.info("[直播时间] 开播前重建 TTS worker,消除长运行性能退化")
try:
ok = await tts.restart_worker(reason="开播前重建")
if not ok:
self.logger.warning("[直播时间] TTS worker 开播前重建未完成,将在首次合成时自动恢复")
except Exception as e:
self.logger.error(f"[直播时间] TTS worker 开播前重建异常: {e}")
async def _run_startup_live_compensation(self, start_at: datetime, end_at: datetime):
livehime_running_at_start = self._is_exe_running("bilibili_live_exe")
self.logger.warning(
"[直播时间] 服务在直播时段内启动,60秒后补启动直播姬和原神,"
"再等待60秒补开启推流"
)
try:
await asyncio.sleep(self.STARTUP_LAUNCH_DELAY_SECONDS)
if not self._startup_window_active(start_at, end_at):
self.logger.info("[直播时间] 启动补偿已取消:当前已不在直播时段")
return
self.logger.info("[直播时间] 启动补偿:启动直播姬和原神")
livehime_launched = False
if self._is_enabled("launch_bilibili_live_enabled"):
livehime_launched = await self._launch_exe("B站直播姬", "bilibili_live_exe")
if self._is_enabled("launch_genshin_enabled"):
await self._launch_exe("原神", "genshin_exe")
await asyncio.sleep(self.STARTUP_PUSH_DELAY_SECONDS)
if not self._startup_window_active(start_at, end_at):
self.logger.info("[直播时间] 启动补偿已取消:等待推流期间已离开直播时段")
return
if not self._is_enabled("bilibili_push_enabled"):
self.logger.info("[直播时间] 启动补偿:自动推流未启用,跳过")
return
if livehime_running_at_start and not livehime_launched:
self.logger.warning(
"[直播时间] 启动补偿:直播姬在服务启动前已运行,为避免误关正在进行的推流,跳过点击"
)
return
self.logger.info("[直播时间] 启动补偿:开启推流")
await self.push_bilibili_live()
except asyncio.CancelledError:
self.logger.info("[直播时间] 启动补偿任务已取消")
raise
except Exception as e:
self.logger.error(f"[直播时间] 启动补偿异常: {e}")
def _schedule_startup_live_compensation(self, start_at: datetime, end_at: datetime) -> bool:
window_key = start_at.isoformat()
if self._startup_compensation_window_key == window_key:
return False
self._startup_compensation_window_key = window_key
# 补偿流程接管本场的准备和开播动作,避免服务恰好在开播分钟启动时重复点击。
self._triggered_events.add(self._event_key("prepare_tts", start_at - timedelta(minutes=15)))
self._triggered_events.add(self._event_key("prepare", start_at - timedelta(minutes=10)))
self._triggered_events.add(self._event_key("start", start_at))
self._startup_compensation_task = asyncio.create_task(
self._run_startup_live_compensation(start_at, end_at),
name="直播启动补偿",
)
return True
async def run(self):
self.ensure_startup_shortcut()
self.logger.info(
f"[直播时间] 调度启动: {self.cfg.get('live_start_time', '--:--')} - "
f"{self.cfg.get('live_end_time', '--:--')}"
)
startup_window = self._current_live_window()
if startup_window:
self._begin_live_statistics(startup_window[0], reason="service_started_during_live_window")
self._schedule_startup_live_compensation(*startup_window)
while not self._stop:
now = datetime.now().replace(second=0, microsecond=0)
today = date.today().isoformat()
try:
for event, when in self._live_occurrences(now):
key = self._event_key(event, when)
if key in self._triggered_events or not self._event_due(now, when):
continue
self._triggered_events.add(key)
if event == "prepare_tts":
self.logger.info("[直播时间] 距离开播15分钟,重建 TTS worker")
await self._restart_tts_worker_before_live()
elif event == "prepare":
self.logger.info("[直播时间] 距离开播10分钟,启动直播姬和原神")
await self._launch_exe("B站直播姬", "bilibili_live_exe")
await self._launch_exe("原神", "genshin_exe")
elif event == "start":
self.logger.info("[直播时间] 到达开播时间,开启推流")
await self.push_bilibili_live()
self._begin_live_statistics(when, reason="scheduled_start")
elif event == "cleanup":
await self._cleanup_before_stop()
elif event == "stop":
self.logger.info("[直播时间] 到达关播时间,停止推流")
stopped = False
try:
stopped = await self.stop_bilibili_live()
except Exception as e:
self.logger.error(f"[直播时间] 推流关闭异常: {e}")
finally:
self._end_live_statistics(when, reason="scheduled_stop")
if not stopped and self._is_enabled("reboot_after_stop_enabled"):
self.logger.warning("[直播时间] 推流关闭未确认,仍按二重保险安排重启")
self._schedule_reboot_after_stop(when)
if self._live_session_id:
self._refresh_live_statistics(now)
# 保留原有可选的每日重启能力,但不再显示在直播时间页面。
if (self._is_enabled("auto_reboot_enabled")
and self._last_reboot_date != today
and self._match_time(self.cfg.get("auto_reboot_time", "03:00"))):
self._last_reboot_date = today
self.logger.warning("[直播时间] 到达自动重启时间,60秒后重启电脑")
subprocess.Popen(
["shutdown", "/r", "/t", "60", "/c", "BetterGI直播系统定时自动重启"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
# 只保留最近三天触发记录,避免集合无限增长。
cutoff = (now - timedelta(days=3)).strftime("%Y-%m-%d")
self._triggered_events = {key for key in self._triggered_events if key.split(":", 1)[1][:10] >= cutoff}
except Exception as e:
self.logger.error(f"[直播时间] 调度异常: {e}")
await asyncio.sleep(20)
def stop(self):
self._end_live_statistics(reason="service_shutdown")
self._stop = True
if self._startup_compensation_task and not self._startup_compensation_task.done():
self._startup_compensation_task.cancel()
class QueueSystem:
"""整合所有模块,管理配置组完成/默认组/窗口超时的后台逻辑。"""
def __init__(self, config: Config, logger: logging.Logger,
stats_store: StatsStore | None = None):
self.config = config
self.logger = logger
self.stats_store = stats_store
self.health = ServiceRegistry(stats_store)
self.live_client = None
self.health.set("主程序", ServiceRegistry.STARTING, "初始化")
self.health.set("配置", ServiceRegistry.RUNNING, f"revision={config.revision}")
self.user_mgr = UserManager(
config.data_dir,
logger,
config=config,
stats_store=stats_store,
)
self.queue_mgr = QueueManager(config.data_dir, logger, stats_store=stats_store)
redemption_database = (
stats_store.database_path
if stats_store is not None
else Path(config.data_dir) / "statistics.sqlite3"
)
self.redemption_store = RedemptionCodeStore(redemption_database, stats_store=stats_store)
self.runner = BetterGIRunner(
config.bettergi_exe, config.bettergi_work_dir, logger
)
self.log_monitor = BgiLogMonitor(config.bettergi_work_dir, logger)
self.login_monitor = LoginMonitor(config.bettergi_work_dir, logger)
self._default_start_lock = asyncio.Lock()
self.points_sched = PointsScheduler(
self.user_mgr, self.queue_mgr, self.runner, logger
)
self.song_request_mgr = SongRequestManager(
config,
config.data_dir,
logger,
stats_store=stats_store,
)
self.broadcaster = Broadcaster(config, logger, stats_store=stats_store)
self.points_sched.set_broadcaster(self.broadcaster)
self.handler = CommandHandler(
config, self.user_mgr, self.queue_mgr, self.runner,
self.log_monitor, self.points_sched, logger,
broadcaster=self.broadcaster,
stats_store=stats_store,
redemption_store=self.redemption_store,
)
# 设置回调
self.log_monitor.set_finish_callback(self._on_group_finished)
self.login_monitor.set_logged_in_callback(self._on_logged_in)
self.login_monitor.set_login_failed_callback(self._on_login_failed)
# 让handler能访问system(用于启动默认组)
self.handler.system = self
self.handler.login_monitor = self.login_monitor
self.health.set("主程序", ServiceRegistry.RUNNING, "初始化完成")
def apply_config(self):
self.config.apply_runtime_settings()
self.user_mgr.config = self.config
self.runner.update_config(self.config.bettergi_exe, self.config.bettergi_work_dir)
self.log_monitor.set_work_dir(self.config.bettergi_work_dir)
self.login_monitor.set_work_dir(self.config.bettergi_work_dir)
self.song_request_mgr.apply_config(self.config)
self.broadcaster.reload_config()
self.handler.config = self.config
if self.live_client is not None:
self.live_client.apply_config(self.config.room_id, self.config.bilibili_cookie)
self.scheduler_config_applied = time.time()
self.health.set("配置", ServiceRegistry.RUNNING, f"revision={self.config.revision}")
self.logger.info(f"[配置] 已应用配置 revision={self.config.revision}")
async def broadcast(self, text: str, tts: bool = True, category: str | None = "system"):
"""便捷方法。"""
if self.broadcaster:
await self.broadcaster.broadcast(text, tts=tts, tts_category=category)
async def _on_login_failed(self, status: str):
"""扫码登录失败/超时回调:立即关闭原神并重新启动扫码上号。"""
admin_uid = self.queue_mgr.state.get("current_admin_uid")
uname = "?"
if admin_uid:
uname = self.user_mgr.users.get(str(admin_uid), {}).get("uname", "?")
self.logger.warning(f"[登录] 扫码上号失败: {status}, admin={uname}({admin_uid}),自动重置")
original_login_started_at = self.queue_mgr.state.get("login_started_at")
self.login_monitor.disable()
registry_result = await asyncio.to_thread(delete_mihoyo_sdk_registry, self.logger)
if not registry_result.get("success"):
self.logger.warning(
f"[登录] 扫码失败重置前清理 miHoYoSDK 注册表失败,将继续执行重置: "
f"{registry_result.get('error', '未知错误')}"
)
await self._reset_wait_and_retry_login(
admin_uid,
uname,
"扫码上号失败,正在关闭原神并重新启动扫码上号",
original_login_started_at=original_login_started_at,
)
async def _reset_wait_and_retry_login(
self,
uid: int | None,
uname: str = "?",
message: str | None = None,
original_login_started_at: str | None = None,
):
"""统一自动重置:确认队首未变化后,关闭原神并立即启动扫码上号。"""
state = self.queue_mgr.state
if not uid or state.get("current_admin_uid") != uid:
self.logger.info(f"[自动重置] {uname}({uid}) 已不是当前队首,取消启动扫码上号")
return
await self.handler._perform_reset(
message,
preserve_login_started_at=True,
preserved_login_started_at=original_login_started_at,
)
async def _on_logged_in(self):
"""扫码登录成功的回调(由 LoginMonitor 触发)"""
admin_uid = self.queue_mgr.state.get("current_admin_uid")
uname = "?"
if admin_uid:
uname = self.user_mgr.users.get(
str(admin_uid), {}
).get("uname", "?")
if not admin_uid:
self.logger.info("[登录] 忽略启动残留的已登录状态:当前没有队首")
self.login_monitor.disable()
return
if self.queue_mgr.state.get("login_status") != "logining":
self.logger.info("[登录] 忽略非扫码流程中的已登录状态")
self.login_monitor.disable()
return
self.logger.info(f"[登录] {uname}({admin_uid}) 扫码上号完成,等待账号确认")
self.queue_mgr.set_login_status("confirming")
self.login_monitor.disable()
# 扫码上号配置组完成后必须关闭 BetterGI,否则后续“执行 配置组”会被扫码上号进程占用。
self.logger.info("[登录] 扫码上号完成,关闭 BetterGI,等待用户确认账号")
await self.runner.kill_bgi()
self.log_monitor.set_current_group(None)
await self.broadcast(f"请「{uname}」确认这是你的账号吗,回复是或不是")
async def _on_group_finished(self, group_name: str, run_id: str | None):
"""配置组执行完成回调;仅当前任务实例可结算一次。"""
state = self.queue_mgr.state
current_group = state.get("current_group")
current_run_id = state.get("current_group_run_id")
if not run_id or run_id != current_run_id or group_name != current_group:
self.logger.info(
f"[完成] 忽略迟到/重复配置组回调: group='{group_name}', run_id={run_id}, "
f"current_group='{current_group}', current_run_id={current_run_id}"
)
return
self.logger.info(f"[完成] 配置组 '{group_name}' 执行结束, run_id={run_id}")
start_time = state.get("group_start_time")
run_seconds = 0
if start_time:
try:
run_seconds = max(0, int((datetime.now() - datetime.fromisoformat(start_time)).total_seconds()))
except Exception:
run_seconds = 0
admin_uid = state.get("current_admin_uid")
admin_points = self.user_mgr.get_points(admin_uid) if admin_uid else 0
# 积分已耗尽/扣到负分时,配置组自然结束后不再保留队列,直接进入默认薄荷。
# 积分仍为正且3分钟内完成,才保留当前用户继续二级权限。
keep_current = (
not state.get("default_running")
and admin_points > 0
and run_seconds > 0
and run_seconds < 180
)
# 配置组自然完成后不主动 kill BGI;真正抢占 kill 放到下一个队首发送“上号”时执行。
self.log_monitor.set_current_group(None)
result = self.queue_mgr.group_finished(expected_run_id=run_id, keep_current=keep_current)
if not result.get("accepted"):
self.logger.info(
f"[完成] 配置组实例已被其他入口结算或替换,忽略: group='{group_name}', run_id={run_id}"
)
return
finished_uid = result.get("finished_uid")
finished_uname = "?"
if finished_uid:
finished_uname = self.user_mgr.users.get(
str(finished_uid), {}
).get("uname", "?")
if result.get("kept"):
self.logger.info(
f"[完成] {finished_uname}({finished_uid}) 配置组跑完,用时{run_seconds}秒,保留队列"
)
await self.broadcast(
f"「{finished_uname}」的配置组'{group_name}'执行完毕,未满3分钟,保留队列"
)
else:
self.logger.info(
f"[完成] {finished_uname}({finished_uid}) 配置组跑完,已出队"
)
await self.broadcast(
f"「{finished_uname}」的配置组'{group_name}'执行完毕,已出队"
)
if result.get("kept"):
return
if result.get("was_default_running"):
if result.get("need_default"):
await self._start_default_group()
return
if result["need_default"]:
# 队列空,启动默认薄荷
await self._start_default_group()
else:
# 有下一个用户,提示90秒窗口
new_admin = result.get("promoted_uid") or self.queue_mgr.state["current_admin_uid"]
if new_admin:
uname = self.user_mgr.users.get(
str(new_admin), {}
).get("uname", "?")
self.logger.info(
f"[顶号] {uname}({new_admin}) 成为新队首, "
f"90秒内发\"上号\"开始"
)
await self.broadcast(
f"「{uname}」成为新队首,90秒内发送\"上号\"开始"
)
# 下一位尚未发送“上号”时继续运行默认薄荷;真正上号时由 _cmd_login 收口并停止。
await self._start_default_group()
async def _start_default_group(self):
async with self._default_start_lock:
await self._start_default_group_locked()
async def _start_default_group_locked(self):
"""启动默认薄荷配置组(不扣积分)。
注意:直播刚启动时没有任何账号登录,不允许跑薄荷。
至少一个用户任务结束后,只要没有扫码/确认/已登录或用户配置组在运行,
即使队列中已有等待“上号”的队首,也允许继续运行空闲薄荷。
"""
state = self.queue_mgr.state
if not state.get("has_user_finished_once", False):
self.logger.info("[默认] 尚无用户完成任务,不启动默认薄荷,等待扫码上号")
return
if state.get("default_running"):
return
if state.get("current_group") is not None:
return
if state.get("login_status") is not None:
return
if state.get("reset_on_next_login_uid") is not None:
return
group = self.config.default_group
run_id = uuid.uuid4().hex
waiting_uid = state.get("current_admin_uid")
waiting_name = ""
if waiting_uid:
waiting_name = self.user_mgr.users.get(str(waiting_uid), {}).get("uname", "")
if self.user_mgr._is_masked_uname(waiting_name):
waiting_name = await self.user_mgr.resolve_uname(waiting_uid, fallback="")
if not waiting_name:
waiting_name = str(waiting_uid)
idle_reason = f"等待队首{waiting_name}上号" if waiting_uid else "队列空"
self.logger.info(f"[默认] {idle_reason},启动默认配置组: {group}, run_id={run_id}")
await self.runner.kill_bgi()
state = self.queue_mgr.state
if (
not state.get("has_user_finished_once", False)
or state.get("default_running")
or state.get("current_group") is not None
or state.get("login_status") is not None
or state.get("reset_on_next_login_uid") is not None
):
self.logger.info("[默认] 关闭 BetterGI 期间状态已变化,取消启动默认配置组")
return
self.log_monitor.set_current_group(group, run_id)
ok = await self.runner.start_groups([group])
if not ok:
self.log_monitor.set_current_group(None)
if self.stats_store:
self.stats_store.record_group_run(
run_id,
group_name=group,
status="failed",
ended_at_utc=datetime.now(timezone.utc).isoformat(),
failed_count=1,
payload={"default_running": True, "reason": "start_failed"},
)
self.logger.error(f"[默认] 默认配置组'{group}'启动失败")
return
state["default_running"] = True
state["current_group"] = group
state["current_group_run_id"] = run_id
state["group_start_time"] = datetime.now().isoformat()
state["billing_started_at"] = None
state["billing_last_at"] = None
state["billing_uid"] = None
self.queue_mgr._save()
if self.stats_store:
self.stats_store.record_group_run(
run_id,
group_name=group,
status="running",
payload={"default_running": True},
)
await self.broadcast(f"{idle_reason},启动默认配置组'{group}'", tts=True)
async def _check_login_remind_loop(self):
"""队首上号/确认阶段每20秒向直播间提醒一次。"""
while True:
await asyncio.sleep(2)
try:
state = self.queue_mgr.state
uid = state.get("current_admin_uid")
user_group_running = (
state.get("current_group") is not None
and not state.get("default_running")
)
if not uid or user_group_running:
continue
status = state.get("login_status")
now = time.time()
last = state.get("last_login_remind_at") or 0
try:
last = float(last)
except Exception:
last = 0
if now - last < 60:
continue
uname = self.user_mgr.users.get(str(uid), {}).get("uname", "?")
if status is None and state.get("admin_window_end"):
await self.broadcast(f"请队首「{uname}」发送\"上号\"开始扫码上号", tts=True)
elif status == "logining":
await self.broadcast(f"请队首「{uname}」尽快扫码上号", tts=True)
elif status == "confirming":
await self.broadcast(f"请队首「{uname}」确认是否为本人账号,回复\"\"\"不是\"", tts=True)
else:
continue
state["last_login_remind_at"] = now
self.queue_mgr._save()
except Exception as e:
self.logger.error(f"[上号提醒] 异常: {e}")
async def _handle_confirm_timeout(self, uid: int, uname: str):
"""账号确认阶段超时:直播稳定优先,直接过号并清理状态。"""
self.logger.warning(f"[确认超时] {uname}({uid}) 超过{CONFIRM_TIMEOUT_SECONDS}秒未确认账号,过号")
if self.login_monitor:
self.login_monitor.disable()
self.login_monitor.clear_status_file()
self.log_monitor.set_current_group(None)
await self.runner.kill_bgi()
result = self.queue_mgr.leave_queue(
uid,
action="timeout",
reason="account_confirm_timeout",
)
await self.broadcast(f"「{uname}」超过1分钟未确认账号,已过号", tts=True)
if result.get("was_running"):
self.log_monitor.set_current_group(None)
if not self.queue_mgr.state["queue"]:
self.queue_mgr.state["has_user_finished_once"] = True
self.queue_mgr._save()
await self._start_default_group()
return
new_admin = self.queue_mgr.state.get("current_admin_uid")
if new_admin:
new_uname = self.user_mgr.users.get(str(new_admin), {}).get("uname", "?")
await self.broadcast(f"「{new_uname}」成为新队首,90秒内发送\"上号\"开始", tts=True)
async def _handle_login_timeout(self, uid: int, uname: str):
"""发送“上号”后240秒(4分钟)未完成登录:过号,并让下一位的“上号”触发统一重置。"""
state = self.queue_mgr.state
if state.get("current_admin_uid") != uid or state.get("login_status") != "logining":
return
self.logger.warning(
f"[登录超时] {uname}({uid}) 发送上号后超过{LOGIN_TIMEOUT_SECONDS}秒未完成登录,过号"
)
if self.login_monitor:
self.login_monitor.disable()
self.login_monitor.clear_status_file()
self.log_monitor.set_current_group(None)
result = self.queue_mgr.leave_queue(
uid,
action="timeout",
reason="login_timeout",
)
new_admin = self.queue_mgr.state.get("current_admin_uid")
self.queue_mgr.state["reset_on_next_login_uid"] = new_admin
self.queue_mgr._save()
await self.broadcast(
f"「{uname}」240秒内未完成登录,已过号并移出队列",
tts=True,
)
if result.get("was_running"):
self.log_monitor.set_current_group(None)
if new_admin:
new_uname = self.user_mgr.users.get(str(new_admin), {}).get("uname", "?")
await self.broadcast(
f"「{new_uname}」成为新队首,发送\"上号\"将先重置原神再启动扫码",
tts=True,
)
return
self.queue_mgr.state["has_user_finished_once"] = True
self.queue_mgr._save()
await self.runner.kill_bgi()
await self._start_default_group()
async def _check_login_watchdog_loop(self):
"""直播稳定兜底:扫码240秒(4分钟)未完成则过号;确认阶段1分钟未确认同样过号。"""
while True:
await asyncio.sleep(5)
try:
state = self.queue_mgr.state
uid = state.get("current_admin_uid")
if not uid:
continue
uname = self.user_mgr.users.get(str(uid), {}).get("uname", "?")
status = state.get("login_status")
now = datetime.now()
if status == "logining":
started_at = state.get("login_started_at")
try:
if not started_at:
raise ValueError("login_started_at为空")
elapsed = (now - datetime.fromisoformat(started_at)).total_seconds()
except Exception as exc:
# 登录状态存在但开始时间损坏时,不能每轮按0秒无限等待;
# 按保守策略立即过号,避免队首永久占位。
self.logger.error(
f"[登录兜底] {uname}({uid}) 登录开始时间无效({exc}),立即按登录超时处理"
)
await self._handle_login_timeout(uid, uname)
continue
if elapsed >= LOGIN_TIMEOUT_SECONDS:
await self._handle_login_timeout(uid, uname)
elif status == "confirming":
started_at = state.get("confirm_started_at")
try:
if not started_at:
raise ValueError("confirm_started_at为空")
elapsed = (now - datetime.fromisoformat(started_at)).total_seconds()
except Exception as exc:
self.logger.error(
f"[登录兜底] {uname}({uid}) 确认开始时间无效({exc}),立即按确认超时处理"
)
await self._handle_confirm_timeout(uid, uname)
continue
if elapsed >= CONFIRM_TIMEOUT_SECONDS:
await self._handle_confirm_timeout(uid, uname)
except Exception as e:
self.logger.error(f"[登录兜底] 异常: {e}")
async def _check_group_watchdog_loop(self):
"""直播稳定兜底:状态认为配置组在跑,但 BetterGI 已退出时自动收尾。"""
while True:
await asyncio.sleep(10)
try:
state = self.queue_mgr.state
group = state.get("current_group")
run_id = state.get("current_group_run_id")
if not group or not run_id:
continue
# 刚启动前几秒可能 tasklist 还查不到,留一点缓冲。
start_time = state.get("group_start_time")
elapsed = 999999
if start_time:
try:
elapsed = (datetime.now() - datetime.fromisoformat(start_time)).total_seconds()
except Exception:
elapsed = 999999
if elapsed < 20:
continue
if not self.runner.is_bgi_running():
if self.runner.is_intentional_stop_active():
self.logger.info(
f"[BGI兜底] BetterGI.exe 因主动停止而不存在"
f"({self.runner.intentional_stop_reason()}),跳过异常结束收尾"
)
continue
self.logger.warning(f"[BGI兜底] 状态显示配置组'{group}'运行中,但 BetterGI.exe 不存在,按异常结束处理")
self.log_monitor.set_current_group(None)
await self.broadcast(f"配置组'{group}'异常结束,系统正在自动收尾", tts=True)
if state.get("default_running"):
# 仅收口 watchdog 观察到的同一默认任务实例,避免覆盖期间启动的新实例。
result = self.queue_mgr.interrupt_group(
"bettergi_process_missing",
status="failed",
expected_run_id=run_id,
)
if not result.get("accepted"):
continue
if self.queue_mgr.state.get("login_status") is None:
await self._start_default_group()
else:
# 用户配置组异常退出不能计作 completed;收口后按现有完成流程推进队列。
result = self.queue_mgr.interrupt_group(
"bettergi_process_missing",
status="failed",
expected_run_id=run_id,
)
if not result.get("accepted"):
continue
finished_uid = state.get("billing_uid") or state.get("current_admin_uid")
if finished_uid and finished_uid in state.get("queue", []):
state["queue"].remove(finished_uid)
if state.get("current_admin_uid") == finished_uid:
state["current_admin_uid"] = None
state["login_status"] = None
state["billing_started_at"] = None
state["billing_last_at"] = None
state["billing_uid"] = None
state["admin_window_end"] = None
state["has_user_finished_once"] = True
state["login_session_id"] = None
state["login_started_at"] = None
state["confirm_started_at"] = None
self.queue_mgr._promote_next()
self.queue_mgr._save()
if not state.get("queue"):
await self._start_default_group()
except Exception as e:
self.logger.error(f"[BGI兜底] 异常: {e}")
async def _check_window_timeout_loop(self):
"""后台检查90秒窗口超时"""
while True:
await asyncio.sleep(10)
try:
result = self.queue_mgr.check_admin_window_timeout()
if result["timeout"]:
kicked = result["kicked_uid"]
if kicked:
uname = self.user_mgr.users.get(
str(kicked), {}
).get("uname", "?")
self.logger.info(
f"[过号] {uname}({kicked}) 90秒未操作,过号"
)
await self.broadcast(
f"「{uname}」90秒未操作,过号,已移出队列"
)
if self.queue_mgr.state["queue"]:
new_admin = self.queue_mgr.state["current_admin_uid"]
if new_admin:
uname = self.user_mgr.users.get(
str(new_admin), {}
).get("uname", "?")
self.logger.info(
f"[顶号] {uname}({new_admin}) 成为新临时管理员"
)
await self.broadcast(
f"「{uname}」成为新队首,90秒内发送\"上号\"开始"
)
await self._start_default_group()
except Exception as e:
self.logger.error(f"[窗口检查] 异常: {e}")
async def _check_idle_default_loop(self):
"""兜底确保等待上号期间有默认薄荷;收到“上号”时由命令处理器停止。"""
while True:
await asyncio.sleep(30)
try:
state = self.queue_mgr.state
if (
state.get("has_user_finished_once")
and not state.get("default_running")
and state.get("current_group") is None
and state.get("login_status") is None
and state.get("reset_on_next_login_uid") is None
):
await self._start_default_group()
except Exception as e:
self.logger.error(f"[默认检查] 异常: {e}")
# ============== 日志 ==============
def setup_logger(config: Config) -> logging.Logger:
logger = logging.getLogger("danmu_queue")
logger.setLevel(getattr(logging, config.log_level, logging.INFO))
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
)
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(LOG_DIR / "danmu_queue.log", encoding="utf-8")
fh.setFormatter(fmt)
logger.addHandler(fh)
return logger
def create_logged_task(coro, name: str, logger: logging.Logger, health: ServiceRegistry | None = None) -> asyncio.Task:
"""创建后台任务并记录异常,避免任务静默停止导致直播卡死。"""
task = asyncio.create_task(coro, name=name)
def _done_callback(t: asyncio.Task):
try:
exc = t.exception()
except asyncio.CancelledError:
logger.info(f"[任务守护] {name} 已取消")
if health:
health.set(name, ServiceRegistry.STOPPED, "cancelled")
return
except Exception as e:
logger.error(f"[任务守护] 读取{name}状态失败: {e}")
if health:
health.set(name, ServiceRegistry.FAILED, "read task state failed", str(e))
return
if exc:
logger.error(f"[任务守护] {name} 异常退出: {exc}\n{''.join(traceback.format_exception(type(exc), exc, exc.__traceback__))}")
if health:
health.set(name, ServiceRegistry.FAILED, "task exited with exception", str(exc))
else:
logger.warning(f"[任务守护] {name} 已结束")
if health:
health.set(name, ServiceRegistry.STOPPED, "task ended")
task.add_done_callback(_done_callback)
return task
def handle_asyncio_exception(loop, context):
"""统一记录 asyncio 未捕获异常。"""
logger = logging.getLogger("danmu_queue")
msg = context.get("message", "asyncio 未捕获异常")
exc = context.get("exception")
if exc:
logger.error(f"[异步异常] {msg}: {exc}\n{''.join(traceback.format_exception(type(exc), exc, exc.__traceback__))}")
else:
logger.error(f"[异步异常] {msg}: {context}")
async def run_forever_logged(name: str, factory, logger: logging.Logger,
restart_delay: float = 3.0,
health: ServiceRegistry | None = None):
"""循环运行可恢复后台任务,异常后记录并自动重启。"""
while True:
try:
logger.info(f"[任务守护] {name} 启动")
if health:
health.set(name, ServiceRegistry.RUNNING, "running")
await factory()
logger.warning(f"[任务守护] {name} 正常结束,{restart_delay}秒后重启")
if health:
health.set(name, ServiceRegistry.DEGRADED, "ended, restarting")
except asyncio.CancelledError:
logger.info(f"[任务守护] {name} 已取消")
if health:
health.set(name, ServiceRegistry.STOPPED, "cancelled")
raise
except Exception as e:
logger.error(f"[任务守护] {name} 异常: {e}\n{''.join(traceback.format_exception(type(e), e, e.__traceback__))}")
if health:
health.set(name, ServiceRegistry.DEGRADED, "exception, restarting", str(e))
health.bump_restart(name)
await asyncio.sleep(restart_delay)
async def watch_config_changes(config: Config, system: "QueueSystem", web_server: "WebServer", logger: logging.Logger):
"""Watch config.json and apply external edits without restarting."""
system.health.set("配置热加载", ServiceRegistry.RUNNING, "watching")
while True:
await asyncio.sleep(1.5)
try:
if config.reload_if_changed():
system.apply_config()
web_server.scheduler.config = config
web_server.music_path = Path(config.data_dir) / "music_state.json"
logger.info(f"[配置] 检测到外部修改,已热加载 revision={config.revision}")
system.health.set("配置热加载", ServiceRegistry.RUNNING, f"revision={config.revision}")
except asyncio.CancelledError:
system.health.set("配置热加载", ServiceRegistry.STOPPED, "cancelled")
raise
except Exception as e:
config.last_error = str(e)
logger.error(f"[配置] 热加载失败: {e}")
system.health.set("配置热加载", ServiceRegistry.DEGRADED, "reload failed", str(e))
# ============== 键鼠输入状态 ==============
class InputStateMonitor:
"""轻量读取当前键鼠状态,供 OBS 前台做触发可视化。"""
VK_MAP = {
"q": 0x51,
"w": 0x57,
"e": 0x45,
"r": 0x52,
"a": 0x41,
"s": 0x53,
"d": 0x44,
"f": 0x46,
"space": 0x20,
}
def __init__(self):
self.available = os.name == "nt"
self._last_pos = None
self._last_dir = "idle"
self._last_move_at = 0.0
self._smooth_dx = 0.0
self._smooth_dy = 0.0
self._lock = threading.Lock()
self._state = {
"keys": {name: False for name in self.VK_MAP},
"mouse": {
"left": False,
"right": False,
"dx": 0,
"dy": 0,
"smooth_dx": 0.0,
"smooth_dy": 0.0,
"magnitude": 0.0,
"direction": "idle",
},
"updated_at": time.time(),
}
self._key_until = {name: 0.0 for name in self.VK_MAP}
self._mouse_until = {"left": 0.0, "right": 0.0}
self._stop = False
self._kb_hook = None
self._ms_hook = None
self._kb_proc = None
self._ms_proc = None
if self.available:
self._thread = threading.Thread(target=self._sample_loop, name="InputStateMonitor", daemon=True)
self._thread.start()
self._hook_thread = threading.Thread(target=self._hook_loop, name="InputHookMonitor", daemon=True)
self._hook_thread.start()
def _key_down(self, vk: int) -> bool:
if not self.available:
return False
try:
return bool(ctypes.windll.user32.GetAsyncKeyState(vk) & 0x8000)
except Exception:
self.available = False
return False
def _cursor_pos(self):
if not self.available:
return None
try:
pt = ctypes.wintypes.POINT()
if ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)):
return int(pt.x), int(pt.y)
except Exception:
self.available = False
return None
def _sample_once(self):
now = time.time()
latch_sec = 0.13
keys_down = {name: self._key_down(vk) for name, vk in self.VK_MAP.items()}
left_down = self._key_down(0x01)
right_down = self._key_down(0x02)
for name, down in keys_down.items():
if down:
self._key_until[name] = now + latch_sec
if left_down:
self._mouse_until["left"] = now + latch_sec
if right_down:
self._mouse_until["right"] = now + latch_sec
pos = self._cursor_pos()
dx = dy = 0
magnitude = 0.0
if pos and self._last_pos:
dx = pos[0] - self._last_pos[0]
dy = pos[1] - self._last_pos[1]
if abs(dx) >= 1 or abs(dy) >= 1:
self._smooth_dx = self._smooth_dx * 0.25 + dx * 0.75
self._smooth_dy = self._smooth_dy * 0.25 + dy * 0.75
magnitude = min(1.0, ((self._smooth_dx ** 2 + self._smooth_dy ** 2) ** 0.5) / 20.0)
horiz = "right" if self._smooth_dx > 0.5 else "left" if self._smooth_dx < -0.5 else ""
vert = "down" if self._smooth_dy > 0.5 else "up" if self._smooth_dy < -0.5 else ""
self._last_dir = "-".join([x for x in [horiz, vert] if x]) or "idle"
self._last_move_at = now
if pos:
self._last_pos = pos
if now - self._last_move_at > 0.40:
direction = "idle"
dx = dy = 0
magnitude = 0.0
self._smooth_dx *= 0.72
self._smooth_dy *= 0.72
else:
direction = self._last_dir
magnitude = max(magnitude, min(1.0, ((self._smooth_dx ** 2 + self._smooth_dy ** 2) ** 0.5) / 20.0))
state = {
"keys": {name: now < until for name, until in self._key_until.items()},
"mouse": {
"left": now < self._mouse_until["left"],
"right": now < self._mouse_until["right"],
"dx": dx,
"dy": dy,
"smooth_dx": round(self._smooth_dx, 2),
"smooth_dy": round(self._smooth_dy, 2),
"magnitude": round(magnitude, 3),
"direction": direction,
},
"updated_at": now,
}
with self._lock:
self._state = state
def _sample_loop(self):
while not self._stop:
try:
self._sample_once()
except Exception:
pass
time.sleep(0.008)
def _latch_key(self, name: str, hold: float = 0.16):
if name in self._key_until:
self._key_until[name] = time.time() + hold
def _latch_mouse(self, name: str, hold: float = 0.16):
if name in self._mouse_until:
self._mouse_until[name] = time.time() + hold
def _hook_loop(self):
try:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
WH_KEYBOARD_LL = 13
WH_MOUSE_LL = 14
WM_KEYDOWN = 0x0100
WM_SYSKEYDOWN = 0x0104
WM_LBUTTONDOWN = 0x0201
WM_RBUTTONDOWN = 0x0204
VK_TO_NAME = {vk: name for name, vk in self.VK_MAP.items()}
LowLevelKeyboardProc = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p)
LowLevelMouseProc = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p)
def kb_proc(n_code, w_param, l_param):
if n_code >= 0 and w_param in (WM_KEYDOWN, WM_SYSKEYDOWN):
vk_code = ctypes.cast(l_param, ctypes.POINTER(ctypes.c_ulong))[0]
name = VK_TO_NAME.get(int(vk_code))
if name:
self._latch_key(name)
return user32.CallNextHookEx(self._kb_hook, n_code, w_param, l_param)
def ms_proc(n_code, w_param, l_param):
if n_code >= 0:
if w_param == WM_LBUTTONDOWN:
self._latch_mouse("left")
elif w_param == WM_RBUTTONDOWN:
self._latch_mouse("right")
return user32.CallNextHookEx(self._ms_hook, n_code, w_param, l_param)
self._kb_proc = LowLevelKeyboardProc(kb_proc)
self._ms_proc = LowLevelMouseProc(ms_proc)
h_mod = kernel32.GetModuleHandleW(None)
self._kb_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._kb_proc, h_mod, 0)
self._ms_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._ms_proc, h_mod, 0)
msg = ctypes.wintypes.MSG()
while not self._stop and user32.GetMessageW(ctypes.byref(msg), None, 0, 0) != 0:
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
except Exception:
pass
def snapshot(self) -> dict:
if not self.available:
return {
"available": False,
"keys": {name: False for name in self.VK_MAP},
"mouse": {"left": False, "right": False, "dx": 0, "dy": 0, "smooth_dx": 0, "smooth_dy": 0, "magnitude": 0, "direction": "idle"},
"updated_at": time.time(),
}
with self._lock:
data = json.loads(json.dumps(self._state))
data["available"] = self.available
return data
# ============== 性能状态 ==============
class PerformanceMonitor:
"""轻量读取 CPU/内存/GPU 占用,供前台显示。"""
def __init__(self):
self.available = os.name == "nt"
self._last_cpu_idle = None
self._last_cpu_total = None
self._last_snapshot = {
"cpu": None,
"memory": None,
"gpu": None,
"gpu_available": False,
"updated_at": time.time(),
}
self._last_at = 0.0
def _filetime_to_int(self, ft) -> int:
return (int(ft.dwHighDateTime) << 32) + int(ft.dwLowDateTime)
def _cpu_percent(self):
if not self.available:
return None
try:
idle = ctypes.wintypes.FILETIME()
kernel = ctypes.wintypes.FILETIME()
user = ctypes.wintypes.FILETIME()
ok = ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user))
if not ok:
return None
idle_v = self._filetime_to_int(idle)
kernel_v = self._filetime_to_int(kernel)
user_v = self._filetime_to_int(user)
total = kernel_v + user_v
if self._last_cpu_total is None:
self._last_cpu_total = total
self._last_cpu_idle = idle_v
return 0.0
total_delta = total - self._last_cpu_total
idle_delta = idle_v - self._last_cpu_idle
self._last_cpu_total = total
self._last_cpu_idle = idle_v
if total_delta <= 0:
return None
return max(0.0, min(100.0, (1.0 - idle_delta / total_delta) * 100.0))
except Exception:
return None
def _memory_percent(self):
if not self.available:
return None
try:
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", ctypes.wintypes.DWORD),
("dwMemoryLoad", ctypes.wintypes.DWORD),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
mem = MEMORYSTATUSEX()
mem.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem)):
return float(mem.dwMemoryLoad)
except Exception:
return None
return None
def _gpu_percent(self):
if os.name != "nt":
return None
# NVIDIA 显卡优先用 nvidia-smi,稳定且更接近任务管理器的总体 GPU 使用率。
nvidia_smi = shutil.which("nvidia-smi") or r"C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe"
if Path(nvidia_smi).exists():
try:
cmd = [nvidia_smi, "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"]
out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL, timeout=1.2, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).strip()
vals = [float(x.strip()) for x in out.splitlines() if x.strip().replace(".", "", 1).isdigit()]
if vals:
return max(0.0, min(100.0, max(vals)))
except Exception:
pass
if not shutil.which("powershell"):
return None
try:
ps = (
"$samples=(Get-Counter '\\GPU Engine(*)\\Utilization Percentage' -ErrorAction SilentlyContinue).CounterSamples;"
"$sum=($samples | Where-Object { $_.InstanceName -match 'engtype_3d|engtype_compute|engtype_copy|engtype_video|engtype_videoencode|engtype_videodecode|engtype_graphics' } | Measure-Object CookedValue -Sum).Sum;"
"if($null -eq $sum){$sum=0}; [Math]::Round([Math]::Min(100,$sum),1)"
)
cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps]
out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL, timeout=2.2, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).strip()
if out:
return max(0.0, min(100.0, float(out.splitlines()[-1].strip())))
except Exception:
pass
try:
# Intel 核显在部分 Windows 版本中 Counter 实例名称不同,第二轮直接汇总所有 GPU Engine。
ps = (
"$samples=(Get-Counter '\\GPU Engine(*)\\Utilization Percentage' -ErrorAction SilentlyContinue).CounterSamples;"
"$sum=($samples | Measure-Object CookedValue -Sum).Sum;"
"if($null -eq $sum){$sum=0}; [Math]::Round([Math]::Min(100,$sum),1)"
)
cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps]
out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL, timeout=2.2, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).strip()
if out:
return max(0.0, min(100.0, float(out.splitlines()[-1].strip())))
except Exception:
return None
return None
def snapshot(self) -> dict:
now = time.time()
if now - self._last_at < 2.0:
return self._last_snapshot
cpu = self._cpu_percent()
memory = self._memory_percent()
gpu = self._gpu_percent()
self._last_snapshot = {
"cpu": None if cpu is None else round(cpu, 1),
"memory": None if memory is None else round(memory, 1),
"gpu": None if gpu is None else round(gpu, 1),
"gpu_available": gpu is not None,
"updated_at": now,
}
self._last_at = now
return self._last_snapshot
# ============== HTTP 服务器 ==============
class WebServer:
"""提供前端页面和 JSON API。"""
def __init__(self, system: "QueueSystem", config: Config,
logger: logging.Logger, port: int = 8086, host: str = "0.0.0.0"):
self.system = system
self.stats_store = system.stats_store
self.config = config
self.logger = logger
self.port = port
self.host = host
self.access_urls = web_access_urls(host, port)
self.web_dir = WEB_DIR
self.admin_dir = self.web_dir / "admin"
self.upload_dir = self.web_dir / "uploads"
self.upload_dir.mkdir(parents=True, exist_ok=True)
self._group_metadata_cache: dict[str, dict[str, Any]] = {}
self._group_metadata_cache_at = 0.0
self._group_metadata_cache_ttl = 60.0
self._runner: web.AppRunner | None = None
self._site: web.TCPSite | None = None
self._closed = asyncio.Event()
self._ready = asyncio.Event()
self._sys_log_lines = []
self._max_log = 500
self.music_path = Path(self.config.data_dir) / "music_state.json"
self.music_state = self._load_music()
self.input_monitor = InputStateMonitor()
self.performance_monitor = PerformanceMonitor()
self.scheduler = SystemScheduler(config, logger, system=system)
self.admin_auth = AdminAuthManager(self.config.data_dir, logger)
self.event_bus = AdminEventBus()
self.bilibili_qr_login: BilibiliQrLogin | None = None
self.netease_qr_login: NeteaseQrLogin | None = None
self._admin_public_paths = {
"/api/admin/bootstrap-status",
"/api/admin/bootstrap",
"/api/admin/login",
"/api/admin/logout",
"/api/admin/session",
}
self._legacy_protected_paths = {
"/api/config",
"/api/users",
"/api/system-log",
"/api/music-monitor-config",
}
# 拦截 logger 输出存一份给前端
self._install_log_capture()
def _install_log_capture(self):
"""拦截 logger 的输出,存到内存供 API 返回。"""
orig_handle = self.logger.handle
web_self = self
def patched(record):
web_self._sys_log_lines.append(record.getMessage())
if len(web_self._sys_log_lines) > web_self._max_log:
web_self._sys_log_lines = web_self._sys_log_lines[-web_self._max_log:]
return orig_handle(record)
self.logger.handle = patched
@web.middleware
async def _admin_auth_middleware(self, request, handler):
path = request.path
if path.startswith("/api/admin/") and path not in self._admin_public_paths:
if not self.admin_auth.is_bootstrapped():
return web.json_response({"success": False, "error": "后台尚未初始化", "code": "BOOTSTRAP_REQUIRED"}, status=428)
if not self._require_admin_session(request):
return web.json_response({"success": False, "error": "请先登录后台", "code": "AUTH_REQUIRED"}, status=401)
elif path in self._legacy_protected_paths:
if not self.admin_auth.is_bootstrapped():
return web.json_response({"success": False, "error": "后台尚未初始化", "code": "BOOTSTRAP_REQUIRED"}, status=428)
if not self._require_admin_session(request):
return web.json_response({"success": False, "error": "请先登录后台", "code": "AUTH_REQUIRED"}, status=401)
return await handler(request)
def _request_ip(self, request) -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return str(request.remote or "")
def _current_session_token(self, request) -> str:
return str(request.cookies.get(SESSION_COOKIE_NAME, "") or "")
def _require_admin_session(self, request) -> dict | None:
token = self._current_session_token(request)
session = self.admin_auth.get_session(token)
if not session:
return None
request["admin_session"] = session
request["admin_session_token"] = token
return session
def _set_session_cookie(self, response: web.StreamResponse, token: str, expires_at: int) -> None:
max_age = max(60, int(expires_at - time.time()))
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
samesite="Strict",
secure=False,
path="/",
)
def _clear_session_cookie(self, response: web.StreamResponse) -> None:
response.del_cookie(SESSION_COOKIE_NAME, path="/")
def _audit(self, request, action: str, *, target: str = "", detail: str = "") -> None:
client_ip = self._request_ip(request)
session_token = self._current_session_token(request)
self.admin_auth.write_audit(
action=action,
target=target,
client_ip=client_ip,
session_id=session_token,
detail=detail,
)
if self.stats_store:
session = request.get("admin_session") or {}
actor = str(
session.get("username")
or session.get("account")
or session.get("admin_id")
or "admin"
)
self.stats_store.record_admin_audit(
uuid.uuid4().hex,
action,
actor=actor,
target_type="admin_target",
target_id=target or None,
success=True,
remote_address=client_ip,
payload={
"detail": detail,
"path": request.path,
"method": request.method,
"session_hash": hashlib.sha256(session_token.encode()).hexdigest() if session_token else "",
},
)
def _payload_signature(self, payload: Any) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _read_bgi_log(self, lines: int = 50) -> list:
"""读取 BetterGI 日志最后 N 行。"""
today = datetime.now().strftime("%Y%m%d")
log_dir = Path(self.config.bettergi_work_dir) / "log"
log_path = log_dir / f"better-genshin-impact{today}.log"
if not log_path.exists():
candidates = sorted(log_dir.glob("better-genshin-impact*.log"), reverse=True)
if not candidates:
return []
log_path = candidates[0]
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return BgiLogMonitor.format_display_lines(all_lines)[-lines:]
except Exception:
return []
def _load_music(self) -> dict:
"""加载音乐状态,文件不存在时返回默认占位。"""
if self.music_path.exists():
try:
with open(self.music_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {
"playing": False,
"current": {
"title": "暂无歌曲",
"artist": "未接入音乐源",
"cover": "",
"duration": 0,
"progress": 0,
},
"playlist": [],
"requests": [],
}
def _save_music(self):
"""保存音乐状态到 data/music_state.json。"""
self.music_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.music_path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.music_state, f, ensure_ascii=False, indent=2)
tmp.replace(self.music_path)
async def start(self):
app = web.Application(middlewares=[self._admin_auth_middleware])
app.router.add_get("/", self._serve_index)
app.router.add_get("/data", self._serve_data)
app.router.add_get("/data/", self._serve_data)
app.router.add_get("/admin", self._serve_admin)
app.router.add_get("/admin/", self._serve_admin)
app.router.add_get("/admin/{tail:.*}", self._serve_admin_asset)
app.router.add_get("/api/state", self._api_state)
app.router.add_get("/api/groups", self._api_groups)
app.router.add_get("/api/config", self._api_config)
app.router.add_post("/api/config", self._api_save_config)
app.router.add_get("/api/users", self._api_users)
app.router.add_get("/api/bgi-log", self._api_bgi_log)
app.router.add_get("/api/system-log", self._api_system_log)
app.router.add_get("/api/danmu", self._api_danmu)
app.router.add_get("/api/music", self._api_music)
app.router.add_post("/api/music", self._api_music_update)
app.router.add_get("/api/input_state", self._api_input_state)
app.router.add_get("/api/performance", self._api_performance)
app.router.add_get("/api/frontend-config", self._api_frontend_config)
app.router.add_get("/api/music-monitor-config", self._api_music_monitor_config)
app.router.add_get("/api/system-schedule-config", self._api_system_schedule_config)
app.router.add_get("/music_cover.jpg", self._serve_cover)
app.router.add_get("/uploads/{name}", self._serve_upload)
app.router.add_get("/api/admin/bootstrap-status", self._api_admin_bootstrap_status)
app.router.add_post("/api/admin/bootstrap", self._api_admin_bootstrap)
app.router.add_post("/api/admin/login", self._api_admin_login)
app.router.add_post("/api/admin/logout", self._api_admin_logout)
app.router.add_get("/api/admin/session", self._api_admin_session)
app.router.add_get("/api/admin/bilibili-qr-status", self._api_admin_bilibili_qr_status)
app.router.add_get("/api/admin/bilibili-qr-image", self._api_admin_bilibili_qr_image)
app.router.add_get("/api/admin/netease-qr-status", self._api_admin_netease_qr_status)
app.router.add_get("/api/admin/netease-qr-image", self._api_admin_netease_qr_image)
app.router.add_get("/api/admin/stream", self._api_admin_stream)
app.router.add_get("/api/admin/state", self._api_admin_state)
app.router.add_get("/api/admin/statistics", self._api_admin_statistics)
app.router.add_get("/api/admin/users", self._api_admin_users)
app.router.add_get("/api/admin/music", self._api_admin_music)
app.router.add_get("/api/admin/logs", self._api_admin_logs)
app.router.add_get("/api/admin/config", self._api_admin_config)
app.router.add_post("/api/admin/config", self._api_admin_save_config)
app.router.add_get("/api/admin/song-search", self._api_admin_song_search)
app.router.add_post("/api/admin/song-requests/add", self._api_admin_song_add)
app.router.add_post("/api/admin/song-requests/move", self._api_admin_song_move)
app.router.add_post("/api/admin/song-requests/play-now", self._api_admin_song_play_now)
app.router.add_post("/api/admin/song-requests/skip-current", self._api_admin_song_skip_current)
app.router.add_post("/api/admin/song-requests/remove", self._api_admin_song_remove)
app.router.add_post("/api/admin/song-requests/clear", self._api_admin_song_clear)
app.router.add_post("/api/admin/song-requests/ban-song", self._api_admin_song_ban_song)
app.router.add_post("/api/admin/song-requests/ban-user", self._api_admin_song_ban_user)
app.router.add_post("/api/admin/users/add-points", self._api_admin_user_add_points)
app.router.add_post("/api/admin/users/set-flags", self._api_admin_user_set_flags)
app.router.add_post("/api/admin/users/delete", self._api_admin_user_delete)
app.router.add_post("/api/admin/users/kick", self._api_admin_user_kick)
app.router.add_get("/api/admin/redemption-codes", self._api_admin_redemption_codes)
app.router.add_post("/api/admin/redemption-codes", self._api_admin_redemption_code_create)
app.router.add_post("/api/admin/redemption-codes/{code_id}/enabled", self._api_admin_redemption_code_enabled)
app.router.add_delete("/api/admin/redemption-codes/{code_id}", self._api_admin_redemption_code_delete)
app.router.add_post("/api/admin/upload_background", self._api_upload_background)
app.router.add_post("/api/admin/action/{action}", self._api_admin_action)
runner = web.AppRunner(app)
await runner.setup()
self._runner = runner
try:
site = web.TCPSite(runner, self.host, self.port)
await site.start()
self._site = site
self._ready.set()
self.system.health.set("Web后台服务", ServiceRegistry.RUNNING, self.access_urls["bind"])
self.logger.info(f"[Web] 本机前台: {self.access_urls['local']['frontend']} 本机后台: {self.access_urls['local']['admin']}")
if self.access_urls["lan_admin"]:
for url in self.access_urls["lan_admin"]:
self.logger.info(f"[Web] 手机后台: {url}")
else:
self.logger.info("[Web] 未发现局域网 IPv4 地址;手机访问时请确认电脑和手机在同一网络")
await self._closed.wait()
except Exception:
self.system.health.set("Web后台服务", ServiceRegistry.FAILED, "startup failed")
raise
finally:
self._site = None
self._runner = None
await runner.cleanup()
async def wait_ready(self, timeout: float = 10.0):
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
async def stop(self):
self._closed.set()
async def _serve_index(self, request):
return web.FileResponse(self.web_dir / "index.html")
async def _serve_data(self, request):
data_page = self.web_dir / "data.html"
if data_page.exists():
return web.FileResponse(data_page)
return web.Response(status=404, text="data page not found")
async def _serve_admin(self, request):
dist_index = self.admin_dir / "index.html"
if dist_index.exists():
return web.FileResponse(dist_index)
return web.Response(status=404, text="admin dist not built")
async def _serve_admin_asset(self, request):
tail = Path(request.match_info["tail"]).as_posix().lstrip("/")
if not tail:
return await self._serve_admin(request)
candidate = (self.admin_dir / tail).resolve()
try:
candidate.relative_to(self.admin_dir.resolve())
except ValueError:
return web.Response(status=404, text="not found")
if candidate.exists() and candidate.is_file():
return web.FileResponse(candidate)
return await self._serve_admin(request)
def _apply_config_change(self, reason: str):
self.system.apply_config()
self.scheduler.config = self.config
self.music_path = Path(self.config.data_dir) / "music_state.json"
self.logger.info(f"[配置] {reason} 已同步到运行中服务")
def _serialize_public_users(self) -> dict[str, dict[str, Any]]:
users = {}
for uid_str, user in self.system.user_mgr.users.items():
users[str(uid_str)] = {
"uname": user.get("uname", "?"),
"points": user.get("points", 0),
}
return users
def _serialize_admin_user(self, uid_str: str, user: dict) -> dict:
uid = int(uid_str)
queue = self.system.queue_mgr.state.get("queue", [])
return {
"uid": uid_str,
"uname": user.get("uname", ""),
"points": user.get("points", 0),
"last_signin_date": user.get("last_signin_date", ""),
"blocked_all": bool(user.get("blocked_all")),
"blocked_queue": bool(user.get("blocked_queue")),
"blocked_song_request": bool(user.get("blocked_song_request")),
"note": user.get("note", ""),
"in_queue": uid in queue,
"role": self.system.handler.get_user_role(uid) if self.system and self.system.handler else ROLE_VIEWER,
}
def _scan_group_metadata(self, *, force: bool = False) -> dict[str, dict[str, Any]]:
now = time.time()
if (
not force
and self._group_metadata_cache
and now - self._group_metadata_cache_at < self._group_metadata_cache_ttl
):
return self._group_metadata_cache
result: dict[str, dict[str, Any]] = {}
script_group_dir = Path(self.config.bettergi_work_dir) / "User" / "ScriptGroup"
auto_pathing_dir = Path(self.config.bettergi_work_dir) / "User" / "AutoPathing"
if script_group_dir.exists() and auto_pathing_dir.exists():
for group_file in script_group_dir.glob("*.json"):
uses_nahida_collect = False
nahida_route_count = 0
try:
group_data = json.loads(group_file.read_text(encoding="utf-8-sig"))
projects = group_data.get("projects", []) if isinstance(group_data, dict) else []
for project in projects:
if not isinstance(project, dict) or project.get("type") != "Pathing":
continue
if str(project.get("status", "Enabled")) != "Enabled":
continue
folder_name = str(project.get("folderName", "") or "")
route_name = str(project.get("name", "") or "")
if not folder_name or not route_name:
continue
route_file = auto_pathing_dir.joinpath(*Path(folder_name).parts, route_name)
try:
route_data = json.loads(route_file.read_text(encoding="utf-8-sig"))
except Exception:
continue
positions = route_data.get("positions", []) if isinstance(route_data, dict) else []
if any(
isinstance(position, dict)
and str(position.get("action", "")).casefold() == "nahida_collect"
for position in positions
):
uses_nahida_collect = True
nahida_route_count += 1
group_name = str(group_data.get("name") or group_file.stem) if isinstance(group_data, dict) else group_file.stem
except Exception:
group_name = group_file.stem
result[group_name] = {
"uses_nahida_collect": uses_nahida_collect,
"nahida_route_count": nahida_route_count,
}
self._group_metadata_cache = result
self._group_metadata_cache_at = now
return result
def _build_available_groups(self) -> list[dict[str, Any]]:
available_file = self.upload_dir / "available_groups.json"
try:
available = json.loads(available_file.read_text(encoding="utf-8-sig"))
except Exception:
available = []
metadata = self._scan_group_metadata()
groups = []
for item in available if isinstance(available, list) else []:
if not isinstance(item, dict) or not item.get("name") or not item.get("image"):
continue
group = dict(item)
meta = metadata.get(str(group.get("name")), {})
group["uses_nahida_collect"] = bool(meta.get("uses_nahida_collect", False))
group["nahida_route_count"] = int(meta.get("nahida_route_count", 0) or 0)
groups.append(group)
return groups
def _build_public_gifts(self) -> list[dict[str, Any]]:
"""公开给前台特效用的最近礼物(精简字段,含折算人民币价值)。"""
gifts = getattr(self.system.handler, "recent_gifts", []) if self.system and self.system.handler else []
result = []
for gift in gifts[-15:]:
_, total_value = bilibili_gift_cny_values(
gift.get("coin_type"),
gift.get("total_coin"),
gift.get("num"),
)
result.append({
"uname": str(gift.get("uname") or ""),
"gift_name": str(gift.get("gift_name") or "礼物"),
"num": int(gift.get("num", 1) or 1),
"ts": float(gift.get("ts", 0) or 0),
"value": round(total_value, 2),
})
return result
def _build_public_state(self) -> dict:
s = self.system.queue_mgr.state
current_group = str(s.get("current_group") or "")
current_group_meta = self._scan_group_metadata().get(current_group, {})
today = date.today().isoformat()
signin_count = sum(
1 for user in self.system.user_mgr.users.values()
if user.get("last_signin_date") == today
)
return {
"queue": [int(x) for x in s["queue"]],
"users": self._serialize_public_users(),
"current_admin": s["current_admin_uid"],
"current_group": s["current_group"],
"current_group_uses_nahida_collect": bool(current_group_meta.get("uses_nahida_collect", False)),
"current_group_nahida_route_count": int(current_group_meta.get("nahida_route_count", 0) or 0),
"group_start_time": s["group_start_time"],
"admin_window_end": s["admin_window_end"],
"default_running": s.get("default_running", False),
"login_status": s.get("login_status"),
"bgi_running": self.system.runner.is_bgi_running(),
"recent_gifts": self._build_public_gifts(),
"today_signins": signin_count,
"today": today,
"config_revision": self.config.revision,
"config_error": self.config.last_error,
"app_root": str(PROJECT_ROOT),
"web_host": self.host,
"web_port": self.port,
"access_urls": self.access_urls,
"service_state": self.system.health.summary(),
"services": self.system.health.snapshot(),
}
def _build_admin_state(self) -> dict:
state = self._build_public_state()
queue_state = self.system.queue_mgr.state
song_state = self.system.song_request_mgr.snapshot()
state.update({
"billing_uid": queue_state.get("billing_uid"),
"current_role": self.system.handler.get_user_role(queue_state.get("current_admin_uid") or 0) if self.system and self.system.handler else ROLE_VIEWER,
"song_queue_count": len(song_state.get("queue", [])),
"active_song_request": song_state.get("active"),
"recent_danmu": list(getattr(self.system.handler, "recent_danmu", [])[-25:]),
"recent_gifts": list(getattr(self.system.handler, "recent_gifts", [])[-25:]),
})
return state
def _build_admin_users(self, query: str = "") -> dict:
q = str(query or "").strip().lower()
users_list = []
for uid_str, user in self.system.user_mgr.users.items():
uname = user.get("uname", "")
if q and q not in uid_str.lower() and q not in uname.lower():
continue
users_list.append(self._serialize_admin_user(uid_str, user))
users_list.sort(key=lambda item: (item["points"], item["uid"]), reverse=True)
return {
"users": users_list,
"queue": list(self.system.queue_mgr.state.get("queue", [])),
"today": date.today().isoformat(),
}
def _build_admin_music(self) -> dict:
self.music_state = self._load_music()
song_state = self.system.song_request_mgr.snapshot()
player_state = song_state.get("player") or {}
banned_users = []
for uid_str in song_state.get("banned_user_ids", []):
user = self.system.user_mgr.users.get(str(uid_str), {})
banned_users.append({
"uid": str(uid_str),
"uname": user.get("uname", f"用户{uid_str}"),
})
# 点歌/背景歌单已改用 mpv 播放后,以 mpv 播放器状态为唯一数据源:
# 只要 mpv 上报过状态就优先用它,换歌间隙也不再回退到 SMTC 音乐监控进程写入的
# online=false,避免前台在换歌空档误报“音乐源离线”。
payload = dict(player_state) if player_state else dict(self.music_state)
payload["requests"] = song_state.get("queue", [])
payload["song_requests"] = song_state
payload["song_requests"]["banned_users"] = banned_users
return payload
def _build_admin_logs(self) -> dict:
return {
"system": self._sys_log_lines[-160:],
"bgi": self._read_bgi_log(160),
}
def _build_admin_config_payload(self) -> dict:
return {
"success": True,
"revision": self.config.revision,
"path": str(self.config.path),
"config": self.config.data,
}
async def _push_admin_sections(self, *sections: str) -> None:
builders = {
"state": self._build_admin_state,
"users": self._build_admin_users,
"music": self._build_admin_music,
"logs": self._build_admin_logs,
"config": self._build_admin_config_payload,
}
for section in sections:
builder = builders.get(section)
if not builder:
continue
payload = builder() if section != "users" else builder("")
await self.event_bus.publish(section, payload)
async def run_admin_sync_loop(self):
last_seen: dict[str, str] = {}
while True:
await asyncio.sleep(1)
if self.event_bus.subscriber_count() <= 0:
continue
snapshots = {
"state": self._build_admin_state(),
"users": self._build_admin_users(""),
"music": self._build_admin_music(),
"logs": self._build_admin_logs(),
"config": self._build_admin_config_payload(),
}
for section, payload in snapshots.items():
signature = self._payload_signature(payload)
if signature == last_seen.get(section):
continue
last_seen[section] = signature
await self.event_bus.publish(section, payload)
async def _api_admin_bootstrap_status(self, request):
ip = self._request_ip(request)
return web.json_response({
"success": True,
"bootstrapped": self.admin_auth.is_bootstrapped(),
"can_bootstrap_here": self.admin_auth.can_bootstrap_ip(ip),
})
async def _api_admin_bootstrap(self, request):
if self.admin_auth.is_bootstrapped():
return web.json_response({"success": False, "error": "后台已初始化"}, status=400)
ip = self._request_ip(request)
if not self.admin_auth.can_bootstrap_ip(ip):
return web.json_response({"success": False, "error": "首次初始化仅允许本机访问"}, status=403)
data = await request.json()
password = str(data.get("password", "") or "")
try:
self.admin_auth.bootstrap(password)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
token, expires_at = self.admin_auth.create_session(ip, request.headers.get("User-Agent", ""))
response = web.json_response({"success": True, "bootstrapped": True, "authenticated": True, "expires_at": expires_at})
self._set_session_cookie(response, token, expires_at)
self.admin_auth.write_audit(action="bootstrap", target="admin", client_ip=ip, session_id=token, detail="init password")
return response
async def _api_admin_login(self, request):
if not self.admin_auth.is_bootstrapped():
return web.json_response({"success": False, "error": "后台尚未初始化"}, status=428)
data = await request.json()
password = str(data.get("password", "") or "")
if not self.admin_auth.verify_password(password):
return web.json_response({"success": False, "error": "密码错误"}, status=401)
ip = self._request_ip(request)
token, expires_at = self.admin_auth.create_session(ip, request.headers.get("User-Agent", ""))
response = web.json_response({"success": True, "authenticated": True, "expires_at": expires_at})
self._set_session_cookie(response, token, expires_at)
self.admin_auth.write_audit(action="login", target="admin", client_ip=ip, session_id=token, detail="shared password login")
return response
async def _api_admin_logout(self, request):
token = self._current_session_token(request)
if token:
self.admin_auth.write_audit(action="logout", target="admin", client_ip=self._request_ip(request), session_id=token, detail="logout")
self.admin_auth.destroy_session(token)
response = web.json_response({"success": True})
self._clear_session_cookie(response)
return response
async def _api_admin_session(self, request):
token = self._current_session_token(request)
session = self.admin_auth.get_session(token)
return web.json_response({
"success": True,
"bootstrapped": self.admin_auth.is_bootstrapped(),
"authenticated": bool(session),
"expires_at": int(session.get("expires_at", 0)) if session else 0,
})
async def _api_admin_bilibili_qr_image(self, request):
if self.bilibili_qr_login is None:
return web.json_response({"success": False, "error": "B站扫码登录尚未初始化"}, status=503)
try:
payload = await self.bilibili_qr_login.qr_png()
except RuntimeError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
return web.Response(
body=payload,
content_type="image/png",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
async def _api_admin_bilibili_qr_status(self, request):
if self.bilibili_qr_login is None:
return web.json_response({"success": False, "error": "B站扫码登录尚未初始化"}, status=503)
return web.json_response(self.bilibili_qr_login.snapshot())
async def _api_admin_netease_qr_status(self, request):
if self.netease_qr_login is None:
return web.json_response({"success": False, "error": "网易云扫码登录尚未初始化"}, status=503)
return web.json_response(self.netease_qr_login.snapshot())
async def _api_admin_netease_qr_image(self, request):
if self.netease_qr_login is None:
return web.json_response({"success": False, "error": "网易云扫码登录尚未初始化"}, status=503)
try:
payload = await self.netease_qr_login.qr_png()
except RuntimeError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
return web.Response(
body=payload,
content_type="image/png",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
async def _api_admin_stream(self, request):
self._require_admin_session(request)
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
queue = await self.event_bus.subscribe()
try:
await response.write(b"retry: 3000\n\n")
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=20)
payload = json.dumps(event["payload"], ensure_ascii=False)
msg = f"id: {event['id']}\nevent: {event['type']}\ndata: {payload}\n\n"
except asyncio.TimeoutError:
msg = ": keepalive\n\n"
await response.write(msg.encode("utf-8"))
except asyncio.CancelledError:
raise
except (
ConnectionResetError,
BrokenPipeError,
aiohttp.ClientConnectionError,
RuntimeError,
):
# 浏览器刷新、关闭后台页面或 EventSource 自动重连时,旧 SSE 连接会正常断开。
# 这不是服务故障,不应让 aiohttp 打印完整异常堆栈。
pass
finally:
await self.event_bus.unsubscribe(queue)
return response
async def _api_admin_state(self, request):
return web.json_response(self._build_admin_state())
async def _api_admin_statistics(self, request):
if not self.stats_store:
return web.json_response({
"success": False,
"error": "统计数据库当前不可用",
"code": "STATS_UNAVAILABLE",
}, status=503)
def parse_date(name: str) -> str | None:
raw = str(request.query.get(name, "") or "").strip()
if not raw:
return None
try:
return date.fromisoformat(raw).isoformat()
except ValueError as exc:
raise web.HTTPBadRequest(
text=json.dumps({
"success": False,
"error": f"{name} 必须为 YYYY-MM-DD",
"code": "INVALID_DATE",
}, ensure_ascii=False),
content_type="application/json",
) from exc
start_date = parse_date("start_date")
end_date = parse_date("end_date")
current_business_date = statistics_business_date()
if not start_date and not end_date:
end_date = current_business_date
start_date = (
date.fromisoformat(current_business_date) - timedelta(days=729)
).isoformat()
if start_date and end_date and start_date > end_date:
return web.json_response({
"success": False,
"error": "开始日期不能晚于结束日期",
"code": "INVALID_DATE_RANGE",
}, status=400)
if start_date and end_date:
span_days = (date.fromisoformat(end_date) - date.fromisoformat(start_date)).days + 1
if span_days > 730:
return web.json_response({
"success": False,
"error": "单次查询最多支持 730 个业务日",
"code": "DATE_RANGE_TOO_LARGE",
}, status=400)
try:
rows = await self.stats_store.query_daily_statistics_strict(start_date, end_date)
except Exception as exc:
self.logger.error(f"[统计看板] 查询每日统计失败: {type(exc).__name__}: {exc}")
return web.json_response({
"success": False,
"error": "统计数据库查询失败,请稍后重试",
"code": "STATS_QUERY_FAILED",
}, status=503)
runtime = self.stats_store.runtime_snapshot()
return web.json_response({
"success": True,
"start_date": start_date,
"end_date": end_date,
"current_business_date": current_business_date,
"rows": rows,
"count": len(rows),
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"database": {
"available": bool(runtime.get("started")),
"writer_alive": bool(runtime.get("writer_alive")),
},
})
async def _api_admin_users(self, request):
return web.json_response(self._build_admin_users(request.query.get("q", "")))
async def _api_admin_music(self, request):
return web.json_response(self._build_admin_music())
async def _api_admin_logs(self, request):
return web.json_response(self._build_admin_logs())
async def _api_admin_config(self, request):
return web.json_response(self._build_admin_config_payload())
async def _api_admin_save_config(self, request):
response = await self._api_save_config(request)
if response.status < 400:
self._audit(request, "save_config", target="config.json", detail="full config save")
await self._push_admin_sections("config", "state")
return response
async def _api_admin_song_search(self, request):
keyword = str(request.query.get("q", "") or "").strip()
if not keyword:
return web.json_response({"success": False, "error": "请输入搜索关键词"}, status=400)
results = await self.system.song_request_mgr.search_songs(keyword, limit=8)
return web.json_response({"success": True, "results": results})
async def _api_admin_song_add(self, request):
data = await request.json()
song = data.get("song") if isinstance(data.get("song"), dict) else data
if not isinstance(song, dict):
return web.json_response({"success": False, "error": "歌曲数据无效"}, status=400)
result = await self.system.song_request_mgr.add_request(0, "后台", song, source="admin")
if not result.get("success"):
return web.json_response(result, status=400)
self._audit(request, "song_add", target=str(song.get("id", "")), detail=str(song.get("name", "")))
await self._push_admin_sections("music", "logs", "state")
return web.json_response(result)
async def _api_admin_song_move(self, request):
data = await request.json()
result = self.system.song_request_mgr.move_request(
str(data.get("id", "") or ""),
int(data.get("to_index", 0)),
)
if not result.get("success"):
return web.json_response(result, status=400)
self._audit(request, "song_move", target=str(data.get("id", "")), detail=f"to={result.get('position')}")
await self._push_admin_sections("music")
return web.json_response(result)
async def _api_admin_song_play_now(self, request):
data = await request.json()
result = await self.system.song_request_mgr.play_now(
song_id=str(data.get("id", "") or ""),
index=int(data["index"]) if data.get("index") is not None else None,
)
if not result.get("success"):
return web.json_response(result, status=400)
item = result.get("item") or {}
self._audit(request, "song_play_now", target=str(item.get("id", "")), detail=str(item.get("name", "")))
await self._push_admin_sections("music", "logs", "state")
return web.json_response(result)
async def _api_admin_song_skip_current(self, request):
result = await self.system.song_request_mgr.skip_current()
if not result.get("success"):
return web.json_response(result, status=400)
item = result.get("item") or {}
self._audit(request, "song_skip_current", target=str(item.get("id", "")), detail=str(item.get("name", "")) or result.get("msg", ""))
await self._push_admin_sections("music", "logs", "state")
return web.json_response(result)
async def _api_admin_song_remove(self, request):
data = await request.json()
result = self.system.song_request_mgr.remove_request(
int(data["index"]) if data.get("index") is not None else None,
str(data.get("id", "") or ""),
)
if not result.get("success"):
return web.json_response(result, status=400)
removed = result.get("removed") or {}
self._audit(request, "song_remove", target=str(removed.get("id", "")), detail=str(removed.get("name", "")))
await self._push_admin_sections("music")
return web.json_response(result)
async def _api_admin_song_clear(self, request):
count = self.system.song_request_mgr.clear_requests()
self._audit(request, "song_clear", target="queue", detail=f"count={count}")
await self._push_admin_sections("music")
return web.json_response({"success": True, "count": count})
async def _api_admin_song_ban_song(self, request):
data = await request.json()
ban = bool(data.get("ban", True))
result = self.system.song_request_mgr.set_song_ban(str(data.get("id", "") or ""), ban=ban)
if not result.get("success"):
return web.json_response(result, status=400)
self._audit(request, "song_ban_song" if ban else "song_unban_song", target=str(data.get("id", "")), detail=str(data.get("name", "")))
await self._push_admin_sections("music")
return web.json_response(result)
async def _api_admin_song_ban_user(self, request):
data = await request.json()
uid = int(data.get("uid", 0) or 0)
if uid <= 0:
return web.json_response({"success": False, "error": "无效用户ID"}, status=400)
ban = bool(data.get("ban", True))
result = self.system.song_request_mgr.set_user_ban(uid, ban=ban)
await self.system.user_mgr.update_flags(uid, blocked_song_request=ban)
self._audit(request, "song_ban_user" if ban else "song_unban_user", target=str(uid), detail=str(data.get("uname", "")))
await self._push_admin_sections("music", "users")
return web.json_response(result)
async def _api_admin_user_add_points(self, request):
data = await request.json()
uid = int(data["uid"])
points = int(data.get("points", 0))
value = await self.system.user_mgr.add_points(
uid,
points,
reason="admin_adjustment",
reference_type="admin_action",
reference_id="user_add_points",
)
self._audit(request, "user_add_points", target=str(uid), detail=f"delta={points} now={value}")
await self._push_admin_sections("users", "state")
return web.json_response({"success": True, "points": value})
async def _api_admin_user_set_flags(self, request):
data = await request.json()
uid = int(data["uid"])
user = await self.system.user_mgr.update_flags(
uid,
blocked_all=data.get("blocked_all") if "blocked_all" in data else None,
blocked_queue=data.get("blocked_queue") if "blocked_queue" in data else None,
blocked_song_request=data.get("blocked_song_request") if "blocked_song_request" in data else None,
note=data.get("note") if "note" in data else None,
)
if "blocked_song_request" in data:
self.system.song_request_mgr.set_user_ban(uid, ban=bool(data.get("blocked_song_request")))
self._audit(request, "user_set_flags", target=str(uid), detail=json.dumps({
"blocked_all": user.get("blocked_all"),
"blocked_queue": user.get("blocked_queue"),
"blocked_song_request": user.get("blocked_song_request"),
}, ensure_ascii=False))
await self._push_admin_sections("users", "music")
return web.json_response({"success": True, "user": self._serialize_admin_user(str(uid), user)})
async def _api_admin_user_delete(self, request):
data = await request.json()
uid = int(data["uid"])
uid_str = str(uid)
if uid_str not in self.system.user_mgr.users:
return web.json_response({"success": False, "error": "用户不存在"}, status=400)
user = self.system.user_mgr.users.pop(uid_str)
await self.system.user_mgr._save()
self._audit(request, "user_delete", target=uid_str, detail=user.get("uname", ""))
await self._push_admin_sections("users", "state")
return web.json_response({"success": True})
async def _api_admin_user_kick(self, request):
data = await request.json()
uid = int(data["uid"])
result = self.system.queue_mgr.leave_queue(
uid,
action="removed",
reason="admin_kick",
)
if not result.get("success"):
return web.json_response({"success": False, "error": result.get("msg", "操作失败")}, status=400)
if result.get("was_admin"):
new_admin = self.system.queue_mgr.state.get("current_admin_uid")
if new_admin:
new_uname = self.system.user_mgr.users.get(str(new_admin), {}).get("uname", "?")
await self.system.broadcast(
f"「{new_uname}」成为新队首,90秒内发送\"上号\"开始",
tts=True,
)
if result.get("was_running"):
await self.system.runner.kill_bgi()
self.system.log_monitor.set_current_group(None)
if not self.system.queue_mgr.state.get("current_admin_uid"):
await self.system._start_default_group()
self._audit(request, "user_kick", target=str(uid), detail=result.get("msg", ""))
await self._push_admin_sections("users", "state", "logs")
return web.json_response({"success": True, **result})
async def _api_admin_redemption_codes(self, request):
code_id_raw = str(request.query.get("code_id", "") or "").strip()
try:
code_id = int(code_id_raw) if code_id_raw else None
codes, records = await asyncio.gather(
self.system.redemption_store.list_codes(),
self.system.redemption_store.list_records(code_id=code_id, limit=500),
)
except (TypeError, ValueError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self.logger.error(f"[兑换码后台] 查询失败: {type(exc).__name__}: {exc}")
return web.json_response({"success": False, "error": "兑换码数据查询失败"}, status=503)
return web.json_response({"success": True, "codes": codes, "records": records})
async def _api_admin_redemption_code_create(self, request):
try:
data = await request.json()
raw_max = data.get("max_redemptions")
max_redemptions = None if raw_max in (None, "") else int(raw_max)
code = await self.system.redemption_store.create_code(
code=str(data.get("code", "") or ""),
points=int(data.get("points", 0)),
starts_at=str(data.get("starts_at", "") or ""),
ends_at=str(data.get("ends_at", "") or ""),
max_redemptions=max_redemptions,
enabled=bool(data.get("enabled", True)),
)
except (TypeError, ValueError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self.logger.error(f"[兑换码后台] 创建失败: {type(exc).__name__}: {exc}")
return web.json_response({"success": False, "error": "兑换码创建失败"}, status=503)
self._audit(
request,
"redemption_code_create",
target=str(code["id"]),
detail=f"code={code['code']} points={code['points']} max={code['max_redemptions']}",
)
return web.json_response({"success": True, "code": code})
async def _api_admin_redemption_code_enabled(self, request):
try:
code_id = int(request.match_info["code_id"])
data = await request.json()
code = await self.system.redemption_store.set_enabled(code_id, bool(data.get("enabled")))
except (TypeError, ValueError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self.logger.error(f"[兑换码后台] 修改状态失败: {type(exc).__name__}: {exc}")
return web.json_response({"success": False, "error": "兑换码状态修改失败"}, status=503)
self._audit(
request,
"redemption_code_enable" if code["enabled"] else "redemption_code_disable",
target=str(code_id),
detail=code["code"],
)
return web.json_response({"success": True, "code": code})
async def _api_admin_redemption_code_delete(self, request):
try:
code_id = int(request.match_info["code_id"])
await self.system.redemption_store.delete_code(code_id)
except (TypeError, ValueError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self.logger.error(f"[兑换码后台] 删除失败: {type(exc).__name__}: {exc}")
return web.json_response({"success": False, "error": "兑换码删除失败"}, status=503)
self._audit(request, "redemption_code_delete", target=str(code_id), detail="soft delete")
return web.json_response({"success": True})
async def _api_config(self, request):
return web.json_response(self._build_admin_config_payload())
async def _api_save_config(self, request):
try:
payload = await request.json()
next_config = payload.get("config", payload) if isinstance(payload, dict) else None
if not isinstance(next_config, dict):
return web.json_response({"success": False, "error": "配置必须是 JSON 对象"}, status=400)
self.config.data = next_config
self.config.save()
self._apply_config_change("完整配置保存")
return web.json_response({"success": True, "revision": self.config.revision, "config": self.config.data})
except Exception as e:
self.config.last_error = str(e)
self.logger.error(f"[配置] 保存完整配置失败: {e}")
return web.json_response({"success": False, "error": str(e)}, status=400)
async def _serve_cover(self, request):
"""提供音乐封面图片。"""
cover = self.web_dir / "music_cover.jpg"
if cover.exists():
return web.FileResponse(cover)
return web.Response(status=404, text="no cover")
async def _serve_upload(self, request):
"""提供后台上传的前台背景图片。"""
name = Path(request.match_info["name"]).name
path = self.upload_dir / name
if path.exists() and path.is_file():
return web.FileResponse(path)
return web.Response(status=404, text="no upload")
async def _api_frontend_config(self, request):
"""返回前台视觉配置。"""
cfg = self.config.data.setdefault("frontend", {})
return web.json_response({
"background_image": cfg.get("background_image", ""),
"background_opacity": float(cfg.get("background_opacity", 0.65)),
"background_blur": float(cfg.get("background_blur", 0)),
"background_fit": cfg.get("background_fit", "cover"),
"theme": cfg.get("theme", "classic"),
})
async def _api_music_monitor_config(self, request):
"""返回音乐监听配置,供后台展示。"""
return web.json_response(self.config.data.setdefault("music_monitor", {}))
async def _api_system_schedule_config(self, request):
"""返回系统定时配置,供后台展示。"""
return web.json_response(self.config.system_cfg)
async def _api_upload_background(self, request):
"""上传前台最底层背景图片。"""
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "background":
return web.json_response({"success": False, "error": "missing background file"}, status=400)
original = Path(field.filename or "background.png").name
suffix = Path(original).suffix.lower()
if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
return web.json_response({"success": False, "error": "unsupported image type"}, status=400)
filename = f"background{suffix}"
path = self.upload_dir / filename
size = 0
with open(path, "wb") as f:
while True:
chunk = await field.read_chunk()
if not chunk:
break
size += len(chunk)
if size > 12 * 1024 * 1024:
try:
path.unlink()
except OSError:
pass
return web.json_response({"success": False, "error": "image too large"}, status=400)
f.write(chunk)
self.config.reload()
cfg = self.config.data.setdefault("frontend", {})
cfg["background_image"] = f"/uploads/{filename}?t={int(time.time())}"
self.config.save()
self._apply_config_change("背景上传")
if request.path.startswith("/api/admin/"):
self._audit(request, "upload_background", target=filename, detail=cfg["background_image"])
await self._push_admin_sections("config")
return web.json_response({"success": True, "url": cfg["background_image"]})
async def _api_state(self, request):
return web.json_response(self._build_public_state())
async def _api_groups(self, request):
return web.json_response({"groups": self._build_available_groups()})
async def _api_users(self, request):
payload = self._build_admin_users(request.query.get("q", ""))
for user in payload["users"]:
user.pop("blocked_all", None)
user.pop("blocked_queue", None)
user.pop("blocked_song_request", None)
user.pop("note", None)
user.pop("role", None)
user.pop("in_queue", None)
return web.json_response(payload)
async def _api_bgi_log(self, request):
lines = int(request.query.get("lines", 50))
return web.json_response({"lines": self._read_bgi_log(lines)})
async def _api_system_log(self, request):
lines = int(request.query.get("lines", 50))
return web.json_response({"lines": self._sys_log_lines[-lines:]})
async def _api_danmu(self, request):
"""返回最近收到的弹幕消息。"""
limit = int(request.query.get("limit", 50))
msgs = getattr(self.system.handler, "recent_danmu", [])
return web.json_response({"messages": msgs[-limit:]})
async def _api_music(self, request):
"""返回当前音乐状态(每次从文件重新加载,兼容外部音乐监控进程)。"""
payload = self._build_admin_music()
payload.pop("song_requests", None)
return web.json_response(payload)
async def _api_music_update(self, request):
"""接收外部音乐源推送的当前播放状态。"""
try:
data = await request.json()
if isinstance(data, dict):
data["requests"] = self.system.song_request_mgr.state.get("queue", [])
self.music_state = data
self._save_music()
await self._push_admin_sections("music")
return web.json_response({"success": True})
return web.json_response({"success": False, "error": "invalid json"}, status=400)
except Exception as e:
return web.json_response({"success": False, "error": str(e)}, status=400)
async def _api_input_state(self, request):
"""返回键鼠输入状态,用于前台键鼠可视化。"""
return web.json_response(self.input_monitor.snapshot())
async def _api_performance(self, request):
"""返回系统性能状态,用于前台性能监测。"""
return web.json_response(self.performance_monitor.snapshot())
async def _persist_netease_music_u(self, music_u: str, account: dict | None = None) -> None:
token = str(music_u or "").strip()
if not token:
raise ValueError("MUSIC_U 不能为空")
request_cfg = self.config.data.setdefault("music_monitor", {}).setdefault("request_player", {})
request_cfg["netease_music_u"] = token
self.config.save()
self._apply_config_change("网易云登录凭据保存")
nickname = str((account or {}).get("nickname") or "网易云用户")
self.logger.info(f"[网易云登录] {nickname} 的 MUSIC_U 已保存并热加载")
await self._push_admin_sections("config", "music")
async def _api_admin_action(self, request):
action = request.match_info["action"]
if action in {"start_bilibili_qr_login", "poll_bilibili_qr_login"}:
if self.bilibili_qr_login is None:
return web.json_response({"success": False, "error": "B站扫码登录尚未初始化"}, status=503)
if action == "start_bilibili_qr_login":
self.logger.info("[Web管理] 申请 B站扫码登录二维码")
result = await self.bilibili_qr_login.start()
self._audit(request, action, target="bilibili")
else:
result = await self.bilibili_qr_login.poll()
return web.json_response(result)
if action in {"start_netease_qr_login", "poll_netease_qr_login"}:
if self.netease_qr_login is None:
return web.json_response({"success": False, "error": "网易云扫码登录尚未初始化"}, status=503)
if action == "start_netease_qr_login":
self.logger.info("[Web管理] 申请网易云扫码登录二维码")
result = await self.netease_qr_login.start()
self._audit(request, action, target="netease")
else:
result = await self.netease_qr_login.poll()
if result.get("state") == "completed":
account = result.get("account") or {}
self._audit(
request,
action,
target="netease",
detail=f"login saved user={account.get('user_id', '')}",
)
return web.json_response(result)
self.logger.info(f"[Web管理] 执行: {action}")
audit_target = ""
audit_detail = ""
if action == "kill_bgi":
self.system.queue_mgr.interrupt_group("admin_kill_bgi", status="cancelled")
await self.system.runner.kill_bgi(reason="后台手动停止")
self.system.log_monitor.set_current_group(None)
elif action == "clear_queue":
self.system.queue_mgr.state["queue"] = []
self.system.queue_mgr.state["current_admin_uid"] = None
self.system.queue_mgr._save()
await self.system._start_default_group()
audit_target = "queue"
elif action == "reset_signin":
for u in self.system.user_mgr.users.values():
u["last_signin_date"] = ""
await self.system.user_mgr._save()
audit_target = "signin"
elif action == "remove_song_request":
data = await request.json()
index = data.get("index", None)
song_id = str(data.get("id", "") or "")
result = self.system.song_request_mgr.remove_request(
int(index) if index is not None else None,
song_id,
)
if not result.get("success"):
return web.json_response(result, status=400)
removed = result.get("removed") or {}
self.logger.info(f"[点歌] 后台移除: {removed.get('name', '?')} - {removed.get('artist', '?')}")
self._audit(request, action, target=str(removed.get("id", "")), detail=str(removed.get("name", "")))
await self._push_admin_sections("music")
return web.json_response(result)
elif action == "clear_song_requests":
count = self.system.song_request_mgr.clear_requests()
self.logger.info(f"[点歌] 后台清空点歌队列: {count} 首")
audit_target = "song_requests"
audit_detail = f"count={count}"
elif action == "add_points":
data = await request.json()
uid = int(data["uid"])
pts = int(data.get("points", 0))
await self.system.user_mgr.add_points(
uid,
pts,
reason="admin_adjustment",
reference_type="admin_action",
reference_id="add_points",
)
audit_target = str(uid)
audit_detail = f"delta={pts}"
elif action == "kick_user":
data = await request.json()
uid = int(data["uid"])
result = self.system.queue_mgr.leave_queue(
uid,
action="removed",
reason="admin_kick",
)
if not result.get("success"):
return web.json_response({"success": False, "error": result.get("msg", "操作失败")}, status=400)
if result.get("was_admin"):
new_admin = self.system.queue_mgr.state.get("current_admin_uid")
if new_admin:
new_uname = self.system.user_mgr.users.get(str(new_admin), {}).get("uname", "?")
await self.system.broadcast(
f"「{new_uname}」成为新队首,90秒内发送\"上号\"开始",
tts=True,
)
# 管理员在跑组中被踢,需要杀BGI
if result.get("was_running"):
await self.system.runner.kill_bgi()
self.system.log_monitor.set_current_group(None)
new_admin = self.system.queue_mgr.state["current_admin_uid"]
if not new_admin:
await self.system._start_default_group()
self.logger.info(f"[Web管理] 踢出用户 {uid}: {result.get('msg')}")
audit_target = str(uid)
audit_detail = result.get("msg", "")
elif action == "delete_user":
data = await request.json()
uid = int(data["uid"])
uid_str = str(uid)
if uid_str in self.system.user_mgr.users:
uname = self.system.user_mgr.users[uid_str].get("uname", "")
del self.system.user_mgr.users[uid_str]
await self.system.user_mgr._save()
self.logger.info(f"[Web管理] 删除用户 {uname}({uid})")
audit_target = uid_str
audit_detail = uname
else:
return web.json_response({"success": False, "error": "用户不存在"}, status=400)
elif action == "save_config":
data = await request.json()
self.config.reload()
cfg = self.config.data
q_cfg = cfg.setdefault("queue", {})
if "initial_points" in data:
global INITIAL_POINTS
INITIAL_POINTS = int(data["initial_points"])
q_cfg["initial_points"] = INITIAL_POINTS
if "signin_points_min" in data or "signin_points_max" in data:
global SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX
SIGNIN_POINTS_MIN = int(data.get("signin_points_min", SIGNIN_POINTS_MIN))
SIGNIN_POINTS_MAX = int(data.get("signin_points_max", SIGNIN_POINTS_MAX))
if SIGNIN_POINTS_MIN > SIGNIN_POINTS_MAX:
SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX = SIGNIN_POINTS_MAX, SIGNIN_POINTS_MIN
q_cfg["signin_points_min"] = SIGNIN_POINTS_MIN
q_cfg["signin_points_max"] = SIGNIN_POINTS_MAX
q_cfg.pop("signin_points", None)
if "signin_reset_hour" in data:
global SIGNIN_RESET_HOUR
SIGNIN_RESET_HOUR = max(0, min(23, int(data["signin_reset_hour"])))
q_cfg["signin_reset_hour"] = SIGNIN_RESET_HOUR
if "max_points" in data:
global MAX_POINTS
MAX_POINTS = int(data["max_points"])
q_cfg["max_points"] = MAX_POINTS
if "admin_window_seconds" in data:
global ADMIN_WINDOW_SECONDS
ADMIN_WINDOW_SECONDS = int(data["admin_window_seconds"])
q_cfg["admin_window_seconds"] = ADMIN_WINDOW_SECONDS
if "default_group" in data:
q_cfg["default_group"] = data["default_group"]
self.config.save()
self._apply_config_change("队列配置保存")
audit_target = "queue_config"
elif action == "save_frontend_config":
data = await request.json()
self.config.reload()
cfg = self.config.data.setdefault("frontend", {})
if "background_opacity" in data:
cfg["background_opacity"] = max(0, min(1, float(data["background_opacity"])))
if "background_blur" in data:
cfg["background_blur"] = max(0, min(30, float(data["background_blur"])))
if "background_image" in data:
cfg["background_image"] = str(data["background_image"] or "")
if "background_fit" in data:
fit = str(data["background_fit"])
cfg["background_fit"] = fit if fit in {"cover", "contain", "fill"} else "cover"
if "theme" in data:
theme = str(data["theme"] or "classic")
cfg["theme"] = theme if theme in {"classic", "aurora"} else "classic"
if data.get("clear_background"):
cfg["background_image"] = ""
self.config.save()
self._apply_config_change("前台配置保存")
audit_target = "frontend_config"
elif action == "test_tts":
data = await request.json()
text = str(data.get("text", "") or "").strip()
play = bool(data.get("play", True))
if not text:
return web.json_response({"success": False, "error": "请输入要合成的文本"}, status=400)
if len(text) > 200:
return web.json_response({"success": False, "error": "测试文本最多200字"}, status=400)
tts = self.system.broadcaster.tts if self.system and self.system.broadcaster else None
if not tts or not tts.enabled or not tts._engine:
return web.json_response({"success": False, "error": "TTS未启用或初始化失败"}, status=400)
start = time.time()
try:
async with tts._synthesis_lock:
audio = await tts._engine._synthesize(text)
duration_ms = int((time.time() - start) * 1000)
if not audio:
return web.json_response({"success": False, "error": "合成返回空音频", "duration_ms": duration_ms}, status=500)
tts._state["total_synthesized"] = tts._state.get("total_synthesized", 0) + 1
tts._state["last_text"] = text
tts._state["last_duration_ms"] = duration_ms
tts._state["last_error"] = ""
tts._save_state()
tts._log_event(f"后台测试合成完成 ({duration_ms}ms): {text[:30]}")
if play:
asyncio.create_task(tts._play_audio(audio))
self._audit(request, action, target=tts.provider, detail=f"test_tts:{text[:24]}")
return web.json_response({
"success": True,
"provider": tts.provider,
"duration_ms": duration_ms,
"audio_bytes": len(audio),
"played": play,
})
except Exception as e:
duration_ms = int((time.time() - start) * 1000)
err = str(e)
if "device-side assert" in err:
err = "CUDA device-side assert:当前 Python 进程的 CUDA 上下文已损坏,必须关闭 DanmuQueue 后重新启动;单纯刷新后台无效。原始错误: " + err
tts._state["total_errors"] = tts._state.get("total_errors", 0) + 1
tts._state["last_error"] = err
tts._save_state()
tts._log_event(f"后台测试异常: {err[:80]}")
self.logger.error(f"[TTS测试] 合成失败: {err}")
return web.json_response({"success": False, "error": err, "duration_ms": duration_ms}, status=500)
elif action == "test_gift_effect":
"""后台礼物特效测试:向 recent_gifts 注入一条模拟礼物,不触发 TTS/统计。"""
data = await request.json()
uname = str(data.get("uname") or "测试观众").strip()[:20] or "测试观众"
gift_name = str(data.get("gift_name") or "小电视飞船").strip()[:20] or "小电视飞船"
try:
num = max(1, min(999, int(data.get("num", 1) or 1)))
except (TypeError, ValueError):
num = 1
try:
value = max(0.0, float(data.get("value", 0) or 0))
except (TypeError, ValueError):
value = 0.0
handler = self.system.handler
fake = {
"uid": None,
"uname": uname,
"gift_name": gift_name,
"num": num,
"ts": time.time(),
"coin_type": "gold" if value > 0 else "silver",
"total_coin": value * BILIBILI_GOLD_COIN_PER_CNY,
"price": value * BILIBILI_GOLD_COIN_PER_CNY,
"source_cmd": "ADMIN_TEST",
}
handler.recent_gifts.append(fake)
if len(handler.recent_gifts) > handler.max_gifts:
handler.recent_gifts.pop(0)
self.logger.info(f"[礼物特效测试] 注入模拟礼物: {uname} {gift_name}x{num}{value})")
self._audit(request, action, target=gift_name, detail=f"{uname} x{num} ¥{value}")
return web.json_response({"success": True, "msg": f"已注入模拟礼物「{gift_name}」x{num},前台约2秒内播放特效"})
elif action == "set_tts_provider":
data = await request.json()
provider = str(data.get("provider", "")).strip()
valid = {"faster-qwen3-tts", "dots-tts", "none"}
if provider not in valid:
return web.json_response({"success": False, "error": f"未知 provider: {provider}"}, status=400)
self.config.reload()
cfg = self.config.data
cfg.setdefault("broadcast", {})["tts_provider"] = provider
self.config.save()
self._apply_config_change("TTS provider 保存")
self.logger.info(f"[TTS] 后台切换 provider: {provider}")
self._audit(request, action, target=provider, detail="set_tts_provider")
await self._push_admin_sections("config")
return web.json_response({"success": True, "provider": provider, "msg": "配置已保存并尝试热同步,实际加载状态请看 TTS 状态"})
elif action == "set_tts_device":
data = await request.json()
device = str(data.get("device", "cuda")).strip()
if device not in {"cuda", "cpu"}:
return web.json_response({"success": False, "error": f"未知 device: {device}"}, status=400)
self.config.reload()
cfg = self.config.data
cfg.setdefault("broadcast", {}).setdefault("tts", {}).setdefault("faster-qwen3-tts", {})["device"] = device
self.config.save()
self._apply_config_change("TTS device 保存")
self.logger.info(f"[TTS] 后台切换 device: {device}")
self._audit(request, action, target=device, detail="set_tts_device")
await self._push_admin_sections("config")
return web.json_response({"success": True, "device": device, "msg": "配置已保存并尝试热同步,实际加载状态请看 TTS 状态"})
elif action == "save_music_monitor_config":
data = await request.json()
self.config.reload()
cfg = self.config.data.setdefault("music_monitor", {})
if "platform" in data:
cfg["platform"] = str(data["platform"]).strip() or "netease"
if "targets" in data:
if isinstance(data["targets"], list):
cfg["targets"] = [str(x).strip() for x in data["targets"] if str(x).strip()]
else:
cfg["targets"] = [x.strip() for x in str(data["targets"]).replace("", ",").split(",") if x.strip()]
if "allow_all" in data:
cfg["allow_all"] = bool(data["allow_all"])
if "interval_sec" in data:
cfg["interval_sec"] = max(0.3, min(10, float(data["interval_sec"])))
if "holdover_ms" in data:
cfg["holdover_ms"] = max(0, min(10000, int(data["holdover_ms"])))
if "prefer_playing" in data:
cfg["prefer_playing"] = bool(data["prefer_playing"])
if "keep_last_when_none" in data:
cfg["keep_last_when_none"] = bool(data["keep_last_when_none"])
if "cover_enabled" in data:
cfg["cover_enabled"] = bool(data["cover_enabled"])
if "extra_filter" in data:
cfg["extra_filter"] = str(data["extra_filter"]).strip()
self.config.save()
self._apply_config_change("音乐监听配置保存")
audit_target = "music_monitor"
elif action == "test_netease_login":
data = await request.json()
music_u = str(data.get("music_u", "") or "").strip()
if not music_u:
return web.json_response({"success": False, "error": "请先填写 MUSIC_U"}, status=400)
resolver = NeteaseResolver(
self.logger,
api_base=str(self.config.data.get("music_monitor", {}).get("request_player", {}).get(
"api_base", "https://music.163.com"
)),
music_u=music_u,
)
status = await resolver.account_status()
if not status.get("authenticated"):
return web.json_response(
{"success": False, "error": "网易云登录无效或已过期,请重新获取 MUSIC_U"},
status=400,
)
await self._persist_netease_music_u(music_u, status)
self._audit(request, action, target="netease", detail="login verified and saved")
return web.json_response({"success": True, "saved": True, **status})
elif action == "test_delete_mihoyo_sdk_registry":
result = await asyncio.to_thread(delete_mihoyo_sdk_registry, self.logger)
audit_target = r"HKCU\Software\miHoYoSDK"
if not result.get("success"):
self._audit(
request,
action,
target=audit_target,
detail=f"failed: {result.get('error', 'unknown error')}",
)
return web.json_response(result, status=500)
audit_detail = "deleted" if result.get("deleted") else "not_found"
self._audit(request, action, target=audit_target, detail=audit_detail)
return web.json_response(result)
elif action == "test_bilibili_push":
ok = await self.scheduler.push_bilibili_live()
if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或点击失败"}, status=400)
audit_target = "bilibili_push"
elif action == "test_bilibili_stop_push":
ok = await self.scheduler.stop_bilibili_live()
if not ok:
return web.json_response({"success": False, "error": "未找到直播姬窗口或关闭推流点击失败"}, status=400)
audit_target = "bilibili_stop_push"
elif action == "save_system_schedule_config":
data = await request.json()
self.config.reload()
cfg = self.config.system_cfg
bool_keys = [
"enable_startup_shortcut", "auto_reboot_enabled",
"launch_bilibili_live_enabled", "launch_genshin_enabled",
"bilibili_push_enabled", "bilibili_stop_push_enabled",
"bilibili_stop_push_confirm_enter",
]
for key in bool_keys:
if key in data:
cfg[key] = bool(data[key])
time_keys = ["live_start_time", "live_end_time", "auto_reboot_time"]
for key in time_keys:
if key in data:
val = str(data[key]).strip()
if not self.scheduler._valid_hhmm(val):
return web.json_response({"success": False, "error": f"{key} 时间必须是有效的 HH:MM,例如 03:00 或 19:30"}, status=400)
cfg[key] = val
path_keys = ["bilibili_live_exe", "genshin_exe"]
for key in path_keys:
if key in data:
cfg[key] = str(data[key]).strip().strip('"')
if "startup_bat" in data:
startup_bat = str(data["startup_bat"]).strip().strip('"') or "run.bat"
if Path(startup_bat).is_absolute() or ".." in Path(startup_bat).parts:
return web.json_response({"success": False, "error": "startup_bat 必须是项目目录内的 BAT 文件名"}, status=400)
if Path(startup_bat).suffix.lower() != ".bat":
return web.json_response({"success": False, "error": "startup_bat 必须是 .bat 文件"}, status=400)
cfg["startup_bat"] = startup_bat
if "bilibili_push_window_keyword" in data:
cfg["bilibili_push_window_keyword"] = str(data["bilibili_push_window_keyword"]).strip() or "直播姬"
for key in [
"bilibili_push_click_x_ratio", "bilibili_push_click_y_ratio",
"bilibili_stop_push_click_x_ratio", "bilibili_stop_push_click_y_ratio",
]:
if key in data:
try:
ratio = float(data[key])
except (TypeError, ValueError):
return web.json_response({"success": False, "error": f"{key} 必须是 0 到 1 的数字"}, status=400)
if not 0 <= ratio <= 1:
return web.json_response({"success": False, "error": f"{key} 必须在 0 到 1 之间"}, status=400)
cfg[key] = ratio
self.config.save()
self._apply_config_change("系统定时配置保存")
self.scheduler.ensure_startup_shortcut()
audit_target = "system_schedule"
else:
return web.json_response({"error": "unknown action"}, status=400)
self._audit(request, action, target=audit_target, detail=audit_detail)
await self._push_admin_sections("state", "users", "music", "logs", "config")
return web.json_response({"success": True})
# ============== 入口 ==============
async def main(host: str = "0.0.0.0", port: int = 8086):
ensure_runtime_dirs()
config_path = CONFIG_DIR / "config.json"
if not config_path.exists():
legacy_config_path = PROJECT_ROOT / "config.json"
if legacy_config_path.exists():
config_path = legacy_config_path
if not config_path.exists():
print("找不到 config.json, 请先配置!")
sys.exit(1)
config = Config(str(config_path))
config.apply_runtime_settings()
logger = setup_logger(config)
loop = asyncio.get_running_loop()
loop.set_exception_handler(handle_asyncio_exception)
stats_store = StatsStore()
stats_started = await stats_store.start()
active_stats_store = stats_store if stats_started else None
process_session_id = uuid.uuid4().hex
if active_stats_store:
active_stats_store.record_process_start(
process_session_id,
pid=os.getpid(),
host=socket.gethostname(),
metadata={
"room_id": config.room_id,
"config_revision": config.revision,
"web_host": host,
"web_port": port,
},
)
else:
logger.warning("统计数据库启动失败,直播主业务将继续运行")
logger.info("=" * 50)
logger.info("BetterGI 弹幕排队系统启动")
logger.info(f"直播间: {config.room_id}")
logger.info(f"BetterGI: {config.bettergi_exe}")
logger.info(f"默认配置组: {config.default_group}")
logger.info(
f"初始积分: {INITIAL_POINTS} | 签到: +{SIGNIN_POINTS_MIN}{SIGNIN_POINTS_MAX} "
f"(北京时间{SIGNIN_RESET_HOUR}:00重置) | 上限: {MAX_POINTS}"
)
logger.info(f"积分扣除: 每{60}秒扣{POINTS_PER_MINUTE}分")
logger.info(f"顶号窗口: {ADMIN_WINDOW_SECONDS}秒")
logger.info("=" * 50)
system = QueueSystem(config, logger, stats_store=active_stats_store)
await system.user_mgr.backfill_points_to_sqlite()
client = BliveClient(
config.room_id,
config.bilibili_cookie,
system.handler,
logger,
stats_store=active_stats_store,
)
system.live_client = client
web_server = WebServer(system, config, logger, port=port, host=host)
health = system.health
credential_store = BilibiliCredentialStore(DATA_DIR / "bilibili_credentials.json")
def apply_refreshed_bilibili_cookie():
system.apply_config()
web_server.scheduler.config = config
logger.info("[B站凭据] 新 Cookie 已热加载,弹幕监听将自动重连")
cookie_refresher = BilibiliCookieRefresher(
credential_store=credential_store,
get_cookie=lambda: config.bilibili_cookie,
update_cookie=config.update_bilibili_cookies,
logger=logger,
check_interval_seconds=int(
float(config.data.get("bilibili", {}).get("cookie_check_interval_hours", 6)) * 3600
),
on_refreshed=apply_refreshed_bilibili_cookie,
is_enabled=lambda: bool(
config.data.get("bilibili", {}).get("cookie_auto_refresh_enabled", True)
),
get_check_interval_seconds=lambda: int(
float(config.data.get("bilibili", {}).get("cookie_check_interval_hours", 6)) * 3600
),
)
async def apply_bilibili_qr_login():
config.data.setdefault("bilibili", {})["cookie_auto_refresh_enabled"] = True
config.save()
apply_refreshed_bilibili_cookie()
cookie_refresher.wake()
await web_server._push_admin_sections("config", "state")
web_server.bilibili_qr_login = BilibiliQrLogin(
credential_store=credential_store,
update_cookie=config.update_bilibili_cookies,
logger=logger,
on_logged_in=apply_bilibili_qr_login,
)
web_server.netease_qr_login = NeteaseQrLogin(
api_base=lambda: str(
config.data.get("music_monitor", {}).get("request_player", {}).get(
"api_base", "https://music.163.com"
)
),
get_saved_music_u=lambda: str(
config.data.get("music_monitor", {}).get("request_player", {}).get(
"netease_music_u", ""
)
),
save_music_u=web_server._persist_netease_music_u,
logger=logger,
)
# Web 端口是主服务的启动闸门:绑定失败时必须终止整套服务,禁止形成幽灵实例。
web_task = create_logged_task(web_server.start(), "Web后台服务", logger, health)
try:
ready_task = asyncio.create_task(web_server.wait_ready(), name="Web后台就绪等待")
done, _ = await asyncio.wait(
{web_task, ready_task},
return_when=asyncio.FIRST_COMPLETED,
)
if web_task in done:
await web_task
raise RuntimeError("Web后台服务在启动阶段意外结束")
await ready_task
except Exception:
web_task.cancel()
await asyncio.gather(web_task, return_exceptions=True)
if active_stats_store:
active_stats_store.record_process_end(
process_session_id,
exit_reason="web_startup_failed",
)
await active_stats_store.close()
logger.critical(f"[Web] 无法绑定 {host}:{port},主服务拒绝启动")
raise
# Web 已成功绑定后才启动业务循环。
tasks = [
web_task,
create_logged_task(run_forever_logged("BGI日志监控", system.log_monitor.run, logger, health=health), "BGI日志监控", logger, health),
create_logged_task(run_forever_logged("登录状态监控", system.login_monitor.run, logger, health=health), "登录状态监控", logger, health),
create_logged_task(run_forever_logged("积分扣除调度", system.points_sched.run, logger, health=health), "积分扣除调度", logger, health),
create_logged_task(run_forever_logged("上号提醒循环", system._check_login_remind_loop, logger, health=health), "上号提醒循环", logger, health),
create_logged_task(run_forever_logged("登录兜底循环", system._check_login_watchdog_loop, logger, health=health), "登录兜底循环", logger, health),
create_logged_task(run_forever_logged("BGI兜底循环", system._check_group_watchdog_loop, logger, health=health), "BGI兜底循环", logger, health),
create_logged_task(run_forever_logged("队首窗口超时循环", system._check_window_timeout_loop, logger, health=health), "队首窗口超时循环", logger, health),
create_logged_task(run_forever_logged("默认组空闲循环", system._check_idle_default_loop, logger, health=health), "默认组空闲循环", logger, health),
create_logged_task(run_forever_logged("点歌播放调度", lambda: system.song_request_mgr.run(lambda: web_server.music_state), logger, health=health), "点歌播放调度", logger, health),
create_logged_task(run_forever_logged("弹幕播报队列", system.broadcaster.run, logger, health=health), "弹幕播报队列", logger, health),
create_logged_task(run_forever_logged("系统定时任务", web_server.scheduler.run, logger, health=health), "系统定时任务", logger, health),
create_logged_task(run_forever_logged("后台实时推送", web_server.run_admin_sync_loop, logger, health=health), "后台实时推送", logger, health),
create_logged_task(watch_config_changes(config, system, web_server, logger), "配置热加载", logger, health),
]
tasks.append(create_logged_task(cookie_refresher.run(), "B站Cookie自动续期", logger, health))
# 业务循环已经启动,等待 TTS 完成一次无声预热后再发送开播提示。
await system.broadcaster.start()
# 启动时不自动跑默认薄荷:此时还没有观众账号登录,只等待弹幕排队/扫码上号。
if not system.queue_mgr.state["queue"]:
logger.info("启动时队列为空:等待用户排队和扫码上号,不启动默认薄荷")
await system.broadcast('直播已开启,发送"排队"等待扫码上号')
exit_reason = "shutdown"
try:
await run_client_forever(client, logger, health)
except KeyboardInterrupt:
exit_reason = "keyboard_interrupt"
logger.info("收到退出信号,正在停止...")
except Exception as e:
exit_reason = f"exception:{type(e).__name__}"
logger.critical(f"主程序异常退出: {e}\n{''.join(traceback.format_exception(type(e), e, e.__traceback__))}")
raise
finally:
client.stop()
system.queue_mgr.interrupt_group("service_shutdown", status="interrupted")
system.log_monitor.stop()
system.login_monitor.stop()
system.points_sched.stop()
web_server.scheduler.stop()
cookie_refresher.stop()
await web_server.stop()
await system.handler.close()
await system.broadcaster.close()
await system.song_request_mgr.close()
health.set("主程序", ServiceRegistry.STOPPING, "shutting down")
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
health.set("主程序", ServiceRegistry.STOPPED, "shutdown complete")
if active_stats_store:
active_stats_store.record_process_end(
process_session_id,
exit_reason=exit_reason,
)
await active_stats_store.close()
logger.info("所有后台任务已停止")
if __name__ == "__main__":
try:
parser = argparse.ArgumentParser(description="BetterGI live queue service")
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
parser.add_argument("--port", type=int, default=8086, help="Web service port")
args = parser.parse_args()
asyncio.run(main(host=args.host, port=args.port))
except KeyboardInterrupt:
pass
except Exception as e:
logger = logging.getLogger("danmu_queue")
if logger.handlers:
logger.critical(f"进程级异常退出: {e}\n{''.join(traceback.format_exception(type(e), e, e.__traceback__))}")
else:
print(f"进程级异常退出: {e}", file=sys.stderr)
traceback.print_exc()