""" 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" MANUAL_LIVE_TEST_DURATION_SECONDS = 30 * 60 BILIBILI_LIVE_STATUS_POLL_TIMEOUT_SECONDS = 45.0 BILIBILI_LIVE_STATUS_POLL_INTERVAL_SECONDS = 2.0 BILIBILI_LIVE_STATUS_REFRESH_SECONDS = 60.0 def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]: """递归删除当前 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 if __package__: from .bettergi_current_party import ( CURRENT_PARTY_SCRIPT_NAME, CurrentPartyReadError, PreparedCurrentPartyRead, clear_current_party_status, prepare_current_party_read, read_current_party_status, ) from .bettergi_daily import ( DailyAutomationError, JsonUpdate, apply_json_updates, prepare_commission_current_party_update, prepare_daily_run, prepare_edit_party_update, prepare_switch_party_update, ) else: from bettergi_current_party import ( CURRENT_PARTY_SCRIPT_NAME, CurrentPartyReadError, PreparedCurrentPartyRead, clear_current_party_status, prepare_current_party_read, read_current_party_status, ) from bettergi_daily import ( DailyAutomationError, JsonUpdate, apply_json_updates, prepare_commission_current_party_update, prepare_daily_run, prepare_edit_party_update, prepare_switch_party_update, ) 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], "daily": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR], "switch_party": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR], "edit_party": [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("daily", {}) daily_cfg = self.data["daily"] daily_cfg.setdefault("one_dragon_template", "默认配置") daily_cfg.setdefault("managed_one_dragon_name", "直播系统自动每日") daily_cfg.setdefault("ley_line_craft_resin_before", True) daily_cfg.setdefault("commission_use_current_party", True) daily_cfg.setdefault("current_party_read_timeout_sec", 45) 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) tts_all["faster-qwen3-tts"].setdefault("startup_timeout_seconds", 900) 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": ["执行", "跑", "开始"]}, "daily": {"enabled": True, "aliases": ["自动每日"]}, "switch_party": {"enabled": True, "aliases": ["切换队伍", "更换队伍"]}, "edit_party": {"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 daily_cfg(self) -> dict: return self.data.get("daily", {}) @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.startup_timeout_seconds = max( 60, int(self.cfg.get("startup_timeout_seconds", 900) or 900), ) 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, startup_timeout_seconds=self.startup_timeout_seconds, ) 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._tts_warmup_task: asyncio.Task | None = None 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 _warmup_tts_in_background(self, tts: TTSEngine): try: await tts.warmup() finally: self._tts_warmup_done.set() async def start(self): if self._tts_started: 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) if bool(self._tts_queue_cfg.get("warmup_on_start", True)): warmup_task = asyncio.create_task( self._warmup_tts_in_background(self.tts), name="TTS后台预热", ) self._tts_warmup_task = warmup_task self._tts_worker_tasks.add(warmup_task) warmup_task.add_done_callback(self._tts_worker_tasks.discard) self.logger.info("[TTS队列] 后台预热已启动,不阻塞弹幕监听") else: self._tts_warmup_done.set() self.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 = "执行结束" ONE_DRAGON_FINISH_PATTERN = "一条龙和配置组任务结束" LOG_HEADER_PATTERN = re.compile( r"^\[(?P