10206 lines
464 KiB
Python
10206 lines
464 KiB
Python
"""
|
||
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_v2:Base64 -> 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 获取弹幕服务器和专用 token;token 不得回退为 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 worker:worker 长运行(尤其隔夜)后合成耗时会严重退化,
|
||
趁开播前观众少时主动重建,避免直播初期性能悬崖。"""
|
||
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()
|