Update Live-streaming code (auto-daily features)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SESSION_COOKIE_NAME = "live_admin_session"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def _is_local_ip(ip: str) -> bool:
|
||||
ip = (ip or "").strip()
|
||||
return ip in {"127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"}
|
||||
|
||||
|
||||
class AdminAuthManager:
|
||||
def __init__(self, data_dir: str | Path, logger, *, session_ttl_sec: int = 12 * 60 * 60):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.path = self.data_dir / "admin_auth.json"
|
||||
self.audit_path = self.data_dir / "admin_audit.log"
|
||||
self.logger = logger
|
||||
self.session_ttl_sec = int(session_ttl_sec)
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
self._data = self._default_data()
|
||||
self._load()
|
||||
|
||||
def _default_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"created_at": "",
|
||||
"password_hash": "",
|
||||
"password_salt": "",
|
||||
"password_updated_at": "",
|
||||
}
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
self._data.update(data)
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"[后台认证] 读取认证文件失败: {exc}")
|
||||
|
||||
def _save(self) -> None:
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
def _hash_password(self, password: str, salt: bytes) -> str:
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=2 ** 14,
|
||||
r=8,
|
||||
p=1,
|
||||
dklen=64,
|
||||
)
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
def is_bootstrapped(self) -> bool:
|
||||
return bool(self._data.get("password_hash") and self._data.get("password_salt"))
|
||||
|
||||
def can_bootstrap_ip(self, ip: str) -> bool:
|
||||
return _is_local_ip(ip)
|
||||
|
||||
def bootstrap(self, password: str) -> None:
|
||||
password = str(password or "")
|
||||
if len(password) < 8:
|
||||
raise ValueError("后台密码至少 8 位")
|
||||
salt = secrets.token_bytes(16)
|
||||
now = _utc_now_iso()
|
||||
self._data = {
|
||||
"version": 1,
|
||||
"created_at": self._data.get("created_at") or now,
|
||||
"password_hash": self._hash_password(password, salt),
|
||||
"password_salt": base64.b64encode(salt).decode("ascii"),
|
||||
"password_updated_at": now,
|
||||
}
|
||||
self._sessions.clear()
|
||||
self._save()
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
if not self.is_bootstrapped():
|
||||
return False
|
||||
try:
|
||||
salt = base64.b64decode(self._data["password_salt"])
|
||||
except Exception:
|
||||
return False
|
||||
current = self._hash_password(str(password or ""), salt)
|
||||
return hmac.compare_digest(current, self._data.get("password_hash", ""))
|
||||
|
||||
def create_session(self, client_ip: str, user_agent: str = "") -> tuple[str, int]:
|
||||
self.cleanup_sessions()
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
expires_at = now + self.session_ttl_sec
|
||||
self._sessions[token] = {
|
||||
"created_at": now,
|
||||
"expires_at": expires_at,
|
||||
"client_ip": client_ip or "",
|
||||
"user_agent": (user_agent or "")[:240],
|
||||
}
|
||||
return token, expires_at
|
||||
|
||||
def cleanup_sessions(self) -> None:
|
||||
now = int(time.time())
|
||||
expired = [token for token, session in self._sessions.items() if int(session.get("expires_at", 0)) <= now]
|
||||
for token in expired:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def get_session(self, token: str) -> dict[str, Any] | None:
|
||||
self.cleanup_sessions()
|
||||
if not token:
|
||||
return None
|
||||
session = self._sessions.get(token)
|
||||
if not session:
|
||||
return None
|
||||
session["expires_at"] = int(time.time()) + self.session_ttl_sec
|
||||
return session
|
||||
|
||||
def destroy_session(self, token: str) -> None:
|
||||
if token:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def write_audit(self, *, action: str, target: str = "", client_ip: str = "", session_id: str = "", detail: str = "") -> None:
|
||||
entry = {
|
||||
"at": _utc_now_iso(),
|
||||
"action": action,
|
||||
"target": target,
|
||||
"client_ip": client_ip,
|
||||
"session_id": session_id[:12],
|
||||
"detail": detail,
|
||||
}
|
||||
line = json.dumps(entry, ensure_ascii=False)
|
||||
try:
|
||||
with open(self.audit_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"[后台认证] 写审计日志失败: {exc}")
|
||||
self.logger.info(f"[审计] action={action} target={target or '-'} ip={client_ip or '-'} detail={detail or '-'}")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
class AdminEventBus:
|
||||
def __init__(self):
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
self._counter = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(self) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
async with self._lock:
|
||||
self._subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
async with self._lock:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
def subscriber_count(self) -> int:
|
||||
return len(self._subscribers)
|
||||
|
||||
async def publish(self, event_type: str, payload):
|
||||
self._counter += 1
|
||||
event = {
|
||||
"id": self._counter,
|
||||
"type": event_type,
|
||||
"ts": time.time(),
|
||||
"payload": payload,
|
||||
}
|
||||
for queue in list(self._subscribers):
|
||||
if queue.full():
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
@@ -0,0 +1,181 @@
|
||||
"""BetterGI adapter helpers for reading the active party preset name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .bettergi_daily import DailyAutomationError, JsonUpdate
|
||||
except ImportError:
|
||||
from bettergi_daily import DailyAutomationError, JsonUpdate
|
||||
|
||||
|
||||
CURRENT_PARTY_SCRIPT_NAME = "LiveCurrentParty"
|
||||
CURRENT_PARTY_GROUP_NAME = "直播系统读取当前队伍"
|
||||
CURRENT_PARTY_STATUS_FILE = "status.json"
|
||||
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
class CurrentPartyReadError(DailyAutomationError):
|
||||
"""Raised when the managed current-party reader cannot complete safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedCurrentPartyRead:
|
||||
request_id: str
|
||||
group_name: str
|
||||
status_path: Path
|
||||
updates: tuple[JsonUpdate, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentPartyReadResult:
|
||||
party_name: str
|
||||
candidates: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
raise CurrentPartyReadError(f"未找到{label}: {path}")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CurrentPartyReadError(f"读取{label}失败: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CurrentPartyReadError(f"{label}必须是 JSON 对象: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def _safe_group_name(value: str) -> str:
|
||||
name = str(value or "").strip()
|
||||
if not name:
|
||||
raise CurrentPartyReadError("当前队伍读取配置组名称不能为空")
|
||||
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
|
||||
raise CurrentPartyReadError(f"当前队伍读取配置组名称包含非法字符: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def _next_group_index(group_dir: Path) -> int:
|
||||
indexes: list[int] = []
|
||||
for path in group_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
value = data.get("index") if isinstance(data, dict) else None
|
||||
if isinstance(value, int):
|
||||
indexes.append(value)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return max(indexes, default=0) + 1
|
||||
|
||||
|
||||
def _load_group_template(group_dir: Path, managed_path: Path) -> dict[str, Any]:
|
||||
if managed_path.is_file():
|
||||
return _read_json_object(managed_path, "托管当前队伍读取配置组")
|
||||
|
||||
for name in ("切换队伍", "修改队员", "每日委托"):
|
||||
candidate = group_dir / f"{name}.json"
|
||||
if candidate.is_file():
|
||||
template = _read_json_object(candidate, f"配置组“{name}”")
|
||||
template["index"] = _next_group_index(group_dir)
|
||||
return template
|
||||
raise CurrentPartyReadError(
|
||||
"无法生成当前队伍读取配置组:请先在 BGI 创建“切换队伍”“修改队员”或“每日委托”中的任意一个配置组"
|
||||
)
|
||||
|
||||
|
||||
def prepare_current_party_read(
|
||||
work_dir: str | Path,
|
||||
request_id: str,
|
||||
*,
|
||||
group_name: str = CURRENT_PARTY_GROUP_NAME,
|
||||
) -> PreparedCurrentPartyRead:
|
||||
work_path = Path(work_dir)
|
||||
if not work_path.is_dir():
|
||||
raise CurrentPartyReadError(f"BetterGI 工作目录不存在: {work_path}")
|
||||
request = str(request_id or "").strip()
|
||||
if not _REQUEST_ID_PATTERN.fullmatch(request):
|
||||
raise CurrentPartyReadError("当前队伍读取请求 ID 格式无效")
|
||||
managed_name = _safe_group_name(group_name)
|
||||
|
||||
group_dir = work_path / "User" / "ScriptGroup"
|
||||
if not group_dir.is_dir():
|
||||
raise CurrentPartyReadError(f"BGI 配置组目录不存在: {group_dir}")
|
||||
managed_path = group_dir / f"{managed_name}.json"
|
||||
managed = copy.deepcopy(_load_group_template(group_dir, managed_path))
|
||||
managed["name"] = managed_name
|
||||
managed["projects"] = [
|
||||
{
|
||||
"name": "读取当前队伍名称",
|
||||
"folderName": CURRENT_PARTY_SCRIPT_NAME,
|
||||
"jsScriptSettingsObject": {"requestId": request},
|
||||
"index": 1,
|
||||
"type": "Javascript",
|
||||
"status": "Enabled",
|
||||
"schedule": "Daily",
|
||||
"runNum": 1,
|
||||
"allowJsNotification": True,
|
||||
"allowJsHTTPHash": "",
|
||||
}
|
||||
]
|
||||
|
||||
status_path = (
|
||||
work_path
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ CURRENT_PARTY_SCRIPT_NAME
|
||||
/ CURRENT_PARTY_STATUS_FILE
|
||||
)
|
||||
update = JsonUpdate(managed_path, managed, "生成当前队伍读取配置组")
|
||||
return PreparedCurrentPartyRead(
|
||||
request_id=request,
|
||||
group_name=managed_name,
|
||||
status_path=status_path,
|
||||
updates=(update,),
|
||||
)
|
||||
|
||||
|
||||
def clear_current_party_status(status_path: str | Path) -> None:
|
||||
path = Path(status_path)
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise CurrentPartyReadError(f"清理当前队伍读取状态失败: {exc}") from exc
|
||||
|
||||
|
||||
def read_current_party_status(
|
||||
status_path: str | Path,
|
||||
request_id: str,
|
||||
) -> CurrentPartyReadResult | None:
|
||||
path = Path(status_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict) or str(data.get("request_id") or "") != request_id:
|
||||
return None
|
||||
|
||||
state = str(data.get("state") or "").strip().casefold()
|
||||
if state in {"", "running"}:
|
||||
return None
|
||||
candidates = tuple(
|
||||
str(value).strip()
|
||||
for value in data.get("candidates", [])
|
||||
if str(value).strip()
|
||||
) if isinstance(data.get("candidates"), list) else ()
|
||||
if state == "success":
|
||||
party_name = str(data.get("party_name") or "").strip()
|
||||
if not party_name:
|
||||
raise CurrentPartyReadError("当前队伍读取脚本返回成功,但队伍名称为空")
|
||||
return CurrentPartyReadResult(party_name=party_name, candidates=candidates)
|
||||
if state == "error":
|
||||
message = str(data.get("message") or "读取当前队伍失败").strip()
|
||||
raise CurrentPartyReadError(message)
|
||||
raise CurrentPartyReadError(f"当前队伍读取脚本返回未知状态: {state}")
|
||||
@@ -0,0 +1,828 @@
|
||||
"""BetterGI managed configuration helpers for daily and party commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class DailyAutomationError(ValueError):
|
||||
"""Raised when a managed BetterGI command cannot be prepared safely."""
|
||||
|
||||
|
||||
DAILY_MODE_NONE = "none"
|
||||
DAILY_MODE_DOMAIN = "domain"
|
||||
DAILY_MODE_LEY_LINE = "ley_line"
|
||||
DAILY_MODE_COMMISSION = "commission"
|
||||
|
||||
LEY_LINE_COUNTRIES = ("蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱")
|
||||
LEY_LINE_TYPE_ALIASES = {
|
||||
"经验": "启示之花",
|
||||
"经验花": "启示之花",
|
||||
"蓝花": "启示之花",
|
||||
"启示": "启示之花",
|
||||
"启示之花": "启示之花",
|
||||
"摩拉": "藏金之花",
|
||||
"摩拉花": "藏金之花",
|
||||
"金币": "藏金之花",
|
||||
"金币花": "藏金之花",
|
||||
"黄花": "藏金之花",
|
||||
"藏金": "藏金之花",
|
||||
"藏金之花": "藏金之花",
|
||||
}
|
||||
|
||||
DAILY_TASK_NAMES = {
|
||||
"mail": "领取邮件",
|
||||
"craft_resin": "合成树脂",
|
||||
"domain": "自动秘境",
|
||||
"ley_line": "自动地脉花",
|
||||
"commission": "每日委托",
|
||||
"serenitea": "领取尘歌壶奖励",
|
||||
"daily_reward": "领取每日奖励",
|
||||
}
|
||||
|
||||
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
_NAME_NORMALIZE = re.compile(r"[\s\-_—-·.。,::,、/|]+")
|
||||
_MEMBER_SEPARATOR = re.compile(r"[\s,,、/|]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyRequest:
|
||||
mode: str = DAILY_MODE_NONE
|
||||
domain_name: str = ""
|
||||
ley_line_type: str = ""
|
||||
ley_line_country: str = ""
|
||||
|
||||
@property
|
||||
def task_name(self) -> str:
|
||||
if self.mode == DAILY_MODE_DOMAIN:
|
||||
return f"自动每日(秘境:{self.domain_name})"
|
||||
if self.mode == DAILY_MODE_LEY_LINE:
|
||||
return f"自动每日(地脉:{self.ley_line_type}/{self.ley_line_country})"
|
||||
if self.mode == DAILY_MODE_COMMISSION:
|
||||
return "自动每日(委托)"
|
||||
return "自动每日"
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
if self.mode == DAILY_MODE_DOMAIN:
|
||||
return f"秘境 {self.domain_name}"
|
||||
if self.mode == DAILY_MODE_LEY_LINE:
|
||||
return f"地脉 {self.ley_line_type} {self.ley_line_country}"
|
||||
if self.mode == DAILY_MODE_COMMISSION:
|
||||
return "每日委托"
|
||||
return "跳过其他任务"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JsonUpdate:
|
||||
path: Path
|
||||
data: dict[str, Any]
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedDailyRun:
|
||||
request: DailyRequest
|
||||
config_name: str
|
||||
updates: tuple[JsonUpdate, ...]
|
||||
requires_current_party: bool = False
|
||||
|
||||
@property
|
||||
def task_name(self) -> str:
|
||||
return self.request.task_name
|
||||
|
||||
|
||||
def _normalize_name(value: str) -> str:
|
||||
return _NAME_NORMALIZE.sub("", str(value or "").strip().casefold())
|
||||
|
||||
|
||||
def _safe_bgi_name(value: str, label: str) -> str:
|
||||
name = str(value or "").strip()
|
||||
if not name:
|
||||
raise DailyAutomationError(f"{label}不能为空")
|
||||
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
|
||||
raise DailyAutomationError(f"{label}包含非法文件名字符: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise DailyAutomationError(f"未找到{label}: {path}")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise DailyAutomationError(f"读取{label}失败: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise DailyAutomationError(f"{label}必须是 JSON 对象: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def parse_daily_request(argument: str) -> DailyRequest:
|
||||
text = str(argument or "").strip()
|
||||
if not text:
|
||||
return DailyRequest()
|
||||
|
||||
parts = text.split()
|
||||
mode = parts[0]
|
||||
if mode == "秘境":
|
||||
domain_name = " ".join(parts[1:]).strip()
|
||||
if not domain_name:
|
||||
raise DailyAutomationError("秘境模式需要指定秘境,例如:自动每日 秘境 风本")
|
||||
return DailyRequest(mode=DAILY_MODE_DOMAIN, domain_name=domain_name)
|
||||
|
||||
if mode in {"地脉", "地脉花"}:
|
||||
if len(parts) != 3:
|
||||
raise DailyAutomationError("地脉模式格式:自动每日 地脉 <经验|摩拉> <国家>")
|
||||
type_name = LEY_LINE_TYPE_ALIASES.get(_normalize_name(parts[1]))
|
||||
if not type_name:
|
||||
raise DailyAutomationError("地脉花类型仅支持经验或摩拉")
|
||||
country = parts[2].strip()
|
||||
if country not in LEY_LINE_COUNTRIES:
|
||||
raise DailyAutomationError(
|
||||
f"不支持的地脉国家'{country}',可用:{'、'.join(LEY_LINE_COUNTRIES)}"
|
||||
)
|
||||
return DailyRequest(
|
||||
mode=DAILY_MODE_LEY_LINE,
|
||||
ley_line_type=type_name,
|
||||
ley_line_country=country,
|
||||
)
|
||||
|
||||
if mode in {"委托", "每日委托"}:
|
||||
if len(parts) != 1:
|
||||
raise DailyAutomationError("委托模式不接受额外参数,格式:自动每日 委托")
|
||||
return DailyRequest(mode=DAILY_MODE_COMMISSION)
|
||||
|
||||
raise DailyAutomationError(
|
||||
"每日模式仅支持:秘境、地脉、委托;不指定模式时直接发送“自动每日”"
|
||||
)
|
||||
|
||||
|
||||
class DomainAliasResolver:
|
||||
def __init__(self, alias_path: Path, bettergi_work_dir: Path):
|
||||
self.alias_path = Path(alias_path)
|
||||
self.bettergi_work_dir = Path(bettergi_work_dir)
|
||||
|
||||
def _available_domains(self) -> list[str]:
|
||||
settings_path = (
|
||||
self.bettergi_work_dir
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ "AutoDomain"
|
||||
/ "settings.json"
|
||||
)
|
||||
if not settings_path.exists():
|
||||
raise DailyAutomationError(
|
||||
f"未找到 BGI 自动秘境设置文件,请安装或更新 AutoDomain 脚本: {settings_path}"
|
||||
)
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise DailyAutomationError(f"读取 BGI 秘境列表失败: {exc}") from exc
|
||||
if not isinstance(settings, list):
|
||||
raise DailyAutomationError("BGI AutoDomain/settings.json 格式无效")
|
||||
for item in settings:
|
||||
if isinstance(item, dict) and item.get("name") == "domainName":
|
||||
options = item.get("options")
|
||||
if isinstance(options, list):
|
||||
domains = [str(value).strip() for value in options if str(value).strip()]
|
||||
if domains:
|
||||
return domains
|
||||
raise DailyAutomationError("BGI AutoDomain/settings.json 中没有秘境名称列表")
|
||||
|
||||
def resolve(self, raw_name: str) -> str:
|
||||
available = self._available_domains()
|
||||
available_set = set(available)
|
||||
alias_data = _read_json_object(self.alias_path, "秘境俗称文件")
|
||||
lookup: dict[str, str] = {}
|
||||
display_terms: dict[str, str] = {}
|
||||
|
||||
def add(term: str, canonical: str) -> None:
|
||||
normalized = _normalize_name(term)
|
||||
if not normalized:
|
||||
return
|
||||
previous = lookup.get(normalized)
|
||||
if previous and previous != canonical:
|
||||
raise DailyAutomationError(
|
||||
f"秘境俗称'{term}'同时指向'{previous}'和'{canonical}'"
|
||||
)
|
||||
lookup[normalized] = canonical
|
||||
display_terms[normalized] = str(term).strip()
|
||||
|
||||
for canonical in available:
|
||||
add(canonical, canonical)
|
||||
|
||||
for canonical, aliases in alias_data.items():
|
||||
if str(canonical).startswith("_"):
|
||||
continue
|
||||
canonical_name = str(canonical).strip()
|
||||
if canonical_name not in available_set:
|
||||
raise DailyAutomationError(
|
||||
f"秘境俗称文件中的正式名称不受当前 BGI 支持: {canonical_name}"
|
||||
)
|
||||
if not isinstance(aliases, list):
|
||||
raise DailyAutomationError(f"秘境'{canonical_name}'的俗称必须是数组")
|
||||
add(canonical_name, canonical_name)
|
||||
for alias in aliases:
|
||||
add(str(alias), canonical_name)
|
||||
|
||||
normalized_input = _normalize_name(raw_name)
|
||||
resolved = lookup.get(normalized_input)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
matches = difflib.get_close_matches(normalized_input, list(lookup), n=3, cutoff=0.45)
|
||||
if matches:
|
||||
suggestions = []
|
||||
for match in matches:
|
||||
canonical = lookup[match]
|
||||
display = display_terms.get(match, canonical)
|
||||
suggestion = canonical if display == canonical else f"{display}({canonical})"
|
||||
if suggestion not in suggestions:
|
||||
suggestions.append(suggestion)
|
||||
raise DailyAutomationError(
|
||||
f"未知秘境'{raw_name}',可能是:{'、'.join(suggestions)}"
|
||||
)
|
||||
raise DailyAutomationError(f"未知秘境'{raw_name}',请检查 config/domain_aliases.json")
|
||||
|
||||
|
||||
def _validate_strategy(work_dir: Path, strategy_name: str, label: str) -> None:
|
||||
strategy = str(strategy_name or "").strip()
|
||||
if not strategy:
|
||||
raise DailyAutomationError(f"{label}未配置战斗策略")
|
||||
auto_fight_dir = work_dir / "User" / "AutoFight"
|
||||
if strategy == "根据队伍自动选择":
|
||||
if not auto_fight_dir.is_dir():
|
||||
raise DailyAutomationError(f"{label}战斗策略目录不存在: {auto_fight_dir}")
|
||||
return
|
||||
json_path = auto_fight_dir / f"{strategy}.json"
|
||||
txt_path = auto_fight_dir / f"{strategy}.txt"
|
||||
if not json_path.is_file() and not txt_path.is_file():
|
||||
raise DailyAutomationError(f"{label}战斗策略文件不存在: {strategy}")
|
||||
|
||||
|
||||
def _template_task_id_candidates(template: dict[str, Any]) -> dict[str, list[str]]:
|
||||
enabled = template.get("TaskEnabledList")
|
||||
order = template.get("TaskOrder")
|
||||
definitions = template.get("TaskDefinitions")
|
||||
if not isinstance(enabled, dict):
|
||||
raise DailyAutomationError("一条龙模板缺少 TaskEnabledList 对象")
|
||||
if order is None:
|
||||
order = []
|
||||
if not isinstance(order, list):
|
||||
raise DailyAutomationError("一条龙模板缺少 TaskOrder 数组")
|
||||
if definitions is None:
|
||||
definitions = {}
|
||||
if not isinstance(definitions, dict):
|
||||
# BetterGI 为每个一条龙配置独立生成任务 ID,只能按任务名复用模板 ID。
|
||||
raise DailyAutomationError("一条龙模板的 TaskDefinitions 必须是对象")
|
||||
|
||||
ordered_ids: list[str] = []
|
||||
for raw_id in [*order, *definitions.keys(), *enabled.keys()]:
|
||||
task_id = str(raw_id or "").strip()
|
||||
if task_id and task_id not in ordered_ids:
|
||||
ordered_ids.append(task_id)
|
||||
|
||||
candidates: dict[str, list[str]] = {}
|
||||
old_format = not definitions
|
||||
for task_id in ordered_ids:
|
||||
raw_name = task_id if old_format else definitions.get(task_id)
|
||||
task_name = str(raw_name or "").strip()
|
||||
if not task_name:
|
||||
continue
|
||||
candidates.setdefault(task_name, []).append(task_id)
|
||||
return candidates
|
||||
|
||||
|
||||
def _build_task_entries(
|
||||
template: dict[str, Any],
|
||||
task_keys: list[str],
|
||||
) -> list[tuple[str, str]]:
|
||||
candidates = _template_task_id_candidates(template)
|
||||
reserved_ids = {
|
||||
task_id
|
||||
for ids in candidates.values()
|
||||
for task_id in ids
|
||||
}
|
||||
used_ids: set[str] = set()
|
||||
entries: list[tuple[str, str]] = []
|
||||
for task_key in task_keys:
|
||||
task_name = DAILY_TASK_NAMES[task_key]
|
||||
task_id = next(
|
||||
(candidate for candidate in candidates.get(task_name, []) if candidate not in used_ids),
|
||||
"",
|
||||
)
|
||||
while not task_id:
|
||||
candidate = str(uuid.uuid4())
|
||||
if candidate not in reserved_ids and candidate not in used_ids:
|
||||
task_id = candidate
|
||||
used_ids.add(task_id)
|
||||
reserved_ids.add(task_id)
|
||||
entries.append((task_id, task_name))
|
||||
return entries
|
||||
|
||||
|
||||
def _build_one_dragon_config(
|
||||
template: dict[str, Any],
|
||||
request: DailyRequest,
|
||||
managed_name: str,
|
||||
ley_line_craft_resin_before: bool,
|
||||
) -> dict[str, Any]:
|
||||
config = copy.deepcopy(template)
|
||||
task_keys = ["mail"]
|
||||
if request.mode != DAILY_MODE_LEY_LINE or ley_line_craft_resin_before:
|
||||
task_keys.append("craft_resin")
|
||||
if request.mode == DAILY_MODE_DOMAIN:
|
||||
task_keys.append("domain")
|
||||
elif request.mode == DAILY_MODE_LEY_LINE:
|
||||
task_keys.append("ley_line")
|
||||
elif request.mode == DAILY_MODE_COMMISSION:
|
||||
task_keys.append("commission")
|
||||
task_keys.extend(["serenitea", "daily_reward"])
|
||||
|
||||
task_entries = _build_task_entries(template, task_keys)
|
||||
task_order = [task_id for task_id, _ in task_entries]
|
||||
config["TaskEnabledList"] = {task_id: True for task_id in task_order}
|
||||
config["TaskOrder"] = task_order
|
||||
config["TaskDefinitions"] = dict(task_entries)
|
||||
config["Name"] = managed_name
|
||||
config["NextTaskId"] = ""
|
||||
config["CompletionAction"] = "无"
|
||||
|
||||
if request.mode == DAILY_MODE_DOMAIN:
|
||||
config["WeeklyDomainEnabled"] = False
|
||||
config["DomainName"] = request.domain_name
|
||||
|
||||
if request.mode == DAILY_MODE_LEY_LINE:
|
||||
config["LeyLineOneDragonMode"] = True
|
||||
config["LeyLineResinExhaustionMode"] = True
|
||||
config["LeyLineOpenModeCountMin"] = False
|
||||
config["LeyLineRunCount"] = 1
|
||||
for day in (
|
||||
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
|
||||
):
|
||||
config[f"LeyLineRun{day}"] = True
|
||||
config[f"LeyLine{day}Type"] = request.ley_line_type
|
||||
config[f"LeyLine{day}Country"] = request.ley_line_country
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_commission_group(
|
||||
work_path: Path,
|
||||
*,
|
||||
use_current_party: bool,
|
||||
) -> bool:
|
||||
group_path = work_path / "User" / "ScriptGroup" / "每日委托.json"
|
||||
group = _read_json_object(group_path, "每日委托配置组")
|
||||
projects = group.get("projects")
|
||||
if not isinstance(projects, list):
|
||||
raise DailyAutomationError("BGI 配置组“每日委托”缺少 projects 数组")
|
||||
enabled_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if isinstance(project, dict)
|
||||
and str(project.get("status", "Enabled")).casefold() != "disabled"
|
||||
]
|
||||
if not enabled_projects:
|
||||
raise DailyAutomationError("BGI 配置组“每日委托”没有启用的可执行项目")
|
||||
|
||||
uses_auto_commission_nova = any(
|
||||
str(project.get("folderName") or "") == "AutoCommissionNova"
|
||||
for project in enabled_projects
|
||||
)
|
||||
if not uses_auto_commission_nova:
|
||||
return False
|
||||
|
||||
user_config_path = (
|
||||
work_path
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ "AutoCommissionNova"
|
||||
/ "Data"
|
||||
/ "user-config.json"
|
||||
)
|
||||
if not user_config_path.is_file():
|
||||
raise DailyAutomationError(
|
||||
"AutoCommissionNova 尚未完成首次配置:缺少 Data/user-config.json;"
|
||||
"请先在 BGI 中手动运行脚本并保存用户配置和战斗策略"
|
||||
)
|
||||
|
||||
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
|
||||
party = user_config.get("party")
|
||||
global_party = party.get("global") if isinstance(party, dict) else None
|
||||
if not isinstance(global_party, dict):
|
||||
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
|
||||
|
||||
missing = []
|
||||
if not use_current_party:
|
||||
if not str(global_party.get("battleTeamName") or "").strip():
|
||||
missing.append("战斗队伍")
|
||||
if not str(global_party.get("elementTeamName") or "").strip():
|
||||
missing.append("元素采集队伍")
|
||||
if missing:
|
||||
raise DailyAutomationError(
|
||||
f"AutoCommissionNova 首次配置不完整,缺少:{'、'.join(missing)}"
|
||||
)
|
||||
strategy_name = str(
|
||||
global_party.get("battleStrategy") or "根据队伍自动选择"
|
||||
).strip()
|
||||
_validate_strategy(work_path, strategy_name, "每日委托")
|
||||
return bool(use_current_party)
|
||||
|
||||
|
||||
def prepare_daily_run(
|
||||
work_dir: str | Path,
|
||||
alias_path: str | Path,
|
||||
argument: str,
|
||||
*,
|
||||
template_name: str,
|
||||
managed_name: str,
|
||||
ley_line_craft_resin_before: bool,
|
||||
commission_use_current_party: bool = False,
|
||||
) -> PreparedDailyRun:
|
||||
work_path = Path(work_dir)
|
||||
if not work_path.is_dir():
|
||||
raise DailyAutomationError(f"BetterGI 工作目录不存在: {work_path}")
|
||||
template = _safe_bgi_name(template_name, "一条龙模板名称")
|
||||
managed = _safe_bgi_name(managed_name, "托管一条龙名称")
|
||||
if template == managed:
|
||||
raise DailyAutomationError("一条龙模板名称不能与托管配置名称相同")
|
||||
|
||||
request = parse_daily_request(argument)
|
||||
updates: list[JsonUpdate] = []
|
||||
requires_current_party = False
|
||||
user_config_path = work_path / "User" / "config.json"
|
||||
|
||||
if request.mode == DAILY_MODE_DOMAIN:
|
||||
resolved = DomainAliasResolver(Path(alias_path), work_path).resolve(request.domain_name)
|
||||
request = replace(request, domain_name=resolved)
|
||||
user_config = _read_json_object(user_config_path, "BGI User/config.json")
|
||||
auto_fight = user_config.get("autoFightConfig")
|
||||
auto_domain = user_config.get("autoDomainConfig")
|
||||
if not isinstance(auto_fight, dict) or not isinstance(auto_domain, dict):
|
||||
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动秘境配置,请升级到 0.63.0+")
|
||||
_validate_strategy(work_path, auto_fight.get("strategyName", ""), "自动秘境")
|
||||
corrected = copy.deepcopy(user_config)
|
||||
corrected["autoDomainConfig"]["specifyResinUse"] = False
|
||||
if corrected != user_config:
|
||||
updates.append(JsonUpdate(user_config_path, corrected, "关闭自动秘境指定树脂次数"))
|
||||
|
||||
elif request.mode == DAILY_MODE_LEY_LINE:
|
||||
user_config = _read_json_object(user_config_path, "BGI User/config.json")
|
||||
auto_fight = user_config.get("autoFightConfig")
|
||||
ley_line = user_config.get("autoLeyLineOutcropConfig")
|
||||
if not isinstance(auto_fight, dict) or not isinstance(ley_line, dict):
|
||||
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动地脉花配置,请升级到 0.63.0+")
|
||||
fight_config = ley_line.get("fightConfig")
|
||||
strategy_name = ""
|
||||
if isinstance(fight_config, dict):
|
||||
strategy_name = str(fight_config.get("strategyName") or "").strip()
|
||||
if not strategy_name:
|
||||
strategy_name = str(auto_fight.get("strategyName") or "").strip()
|
||||
_validate_strategy(work_path, strategy_name, "自动地脉花")
|
||||
if ley_line.get("friendshipTeam") and not ley_line.get("team"):
|
||||
raise DailyAutomationError("BGI 自动地脉花配置了好感队,但未配置战斗队伍")
|
||||
corrected = copy.deepcopy(user_config)
|
||||
corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"] = False
|
||||
if corrected != user_config:
|
||||
updates.append(JsonUpdate(user_config_path, corrected, "关闭地脉花内部合成树脂"))
|
||||
|
||||
elif request.mode == DAILY_MODE_COMMISSION:
|
||||
requires_current_party = _validate_commission_group(
|
||||
work_path,
|
||||
use_current_party=bool(commission_use_current_party),
|
||||
)
|
||||
|
||||
template_path = work_path / "User" / "OneDragon" / f"{template}.json"
|
||||
template_config = _read_json_object(template_path, "一条龙模板")
|
||||
managed_config = _build_one_dragon_config(
|
||||
template_config,
|
||||
request,
|
||||
managed,
|
||||
bool(ley_line_craft_resin_before),
|
||||
)
|
||||
managed_path = work_path / "User" / "OneDragon" / f"{managed}.json"
|
||||
updates.append(JsonUpdate(managed_path, managed_config, "生成直播自动每日一条龙"))
|
||||
return PreparedDailyRun(
|
||||
request=request,
|
||||
config_name=managed,
|
||||
updates=tuple(updates),
|
||||
requires_current_party=requires_current_party,
|
||||
)
|
||||
|
||||
|
||||
def prepare_commission_current_party_update(
|
||||
work_dir: str | Path,
|
||||
party_name: str,
|
||||
) -> JsonUpdate:
|
||||
work_path = Path(work_dir)
|
||||
name = str(party_name or "").strip()
|
||||
if not name:
|
||||
raise DailyAutomationError("当前队伍名称为空")
|
||||
if len(name) > 20 or any(ord(char) < 32 for char in name):
|
||||
raise DailyAutomationError(f"当前队伍名称格式无效: {name}")
|
||||
|
||||
user_config_path = (
|
||||
work_path
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ "AutoCommissionNova"
|
||||
/ "Data"
|
||||
/ "user-config.json"
|
||||
)
|
||||
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
|
||||
corrected = copy.deepcopy(user_config)
|
||||
party = corrected.get("party")
|
||||
global_party = party.get("global") if isinstance(party, dict) else None
|
||||
if not isinstance(global_party, dict):
|
||||
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
|
||||
global_party["battleTeamName"] = name
|
||||
global_party["elementTeamName"] = name
|
||||
return JsonUpdate(
|
||||
user_config_path,
|
||||
corrected,
|
||||
f"将 AutoCommissionNova 战斗及元素采集队伍更新为当前队伍“{name}”",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_script_group_update(
|
||||
work_dir: str | Path,
|
||||
group_name: str,
|
||||
folder_name: str,
|
||||
settings_patch: dict[str, Any],
|
||||
) -> JsonUpdate:
|
||||
work_path = Path(work_dir)
|
||||
safe_group = _safe_bgi_name(group_name, "配置组名称")
|
||||
group_path = work_path / "User" / "ScriptGroup" / f"{safe_group}.json"
|
||||
group = _read_json_object(group_path, f"配置组“{safe_group}”")
|
||||
projects = group.get("projects")
|
||||
if not isinstance(projects, list):
|
||||
raise DailyAutomationError(f"配置组“{safe_group}”缺少 projects 数组")
|
||||
matches = [
|
||||
project
|
||||
for project in projects
|
||||
if isinstance(project, dict)
|
||||
and str(project.get("folderName") or "") == folder_name
|
||||
and str(project.get("status", "Enabled")).casefold() != "disabled"
|
||||
]
|
||||
if not matches:
|
||||
raise DailyAutomationError(
|
||||
f"配置组“{safe_group}”中没有启用的 {folder_name} JavaScript 项目"
|
||||
)
|
||||
if len(matches) > 1:
|
||||
raise DailyAutomationError(
|
||||
f"配置组“{safe_group}”包含多个启用的 {folder_name} 项目,请只保留一个"
|
||||
)
|
||||
settings = matches[0].get("jsScriptSettingsObject")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
matches[0]["jsScriptSettingsObject"] = settings
|
||||
settings.update(settings_patch)
|
||||
return JsonUpdate(group_path, group, f"更新配置组“{safe_group}”参数")
|
||||
|
||||
|
||||
def prepare_switch_party_update(work_dir: str | Path, party_name: str) -> JsonUpdate:
|
||||
party = str(party_name or "").strip()
|
||||
if not party:
|
||||
raise DailyAutomationError("队伍名称不能为空")
|
||||
return _prepare_script_group_update(
|
||||
work_dir,
|
||||
"切换队伍",
|
||||
"AcceleratedEditionSwitchParty",
|
||||
{"partyName": party},
|
||||
)
|
||||
|
||||
|
||||
def _add_character_lookup(
|
||||
lookup: dict[str, str],
|
||||
ambiguous: set[str],
|
||||
raw_name: str,
|
||||
canonical: str,
|
||||
) -> None:
|
||||
key = _normalize_name(raw_name)
|
||||
if not key or key in ambiguous:
|
||||
return
|
||||
previous = lookup.get(key)
|
||||
if previous and previous != canonical:
|
||||
lookup.pop(key, None)
|
||||
ambiguous.add(key)
|
||||
return
|
||||
lookup[key] = canonical
|
||||
|
||||
|
||||
def _character_lookups_from_settings(
|
||||
data: Any,
|
||||
) -> tuple[dict[str, str], dict[str, str]] | None:
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
|
||||
position_options: dict[str, list[str]] = {}
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if name not in {"position1", "position2", "position3", "position4"}:
|
||||
continue
|
||||
options = item.get("options")
|
||||
if isinstance(options, list):
|
||||
position_options[name] = [
|
||||
str(option).strip()
|
||||
for option in options
|
||||
if str(option).strip()
|
||||
]
|
||||
if len(position_options) != 4 or not position_options.get("position1"):
|
||||
return None
|
||||
|
||||
full_lookup: dict[str, str] = {}
|
||||
simple_lookup: dict[str, str] = {}
|
||||
ambiguous_full: set[str] = set()
|
||||
ambiguous_simple: set[str] = set()
|
||||
for option in position_options["position1"]:
|
||||
_add_character_lookup(full_lookup, ambiguous_full, option, option)
|
||||
simple_name = option.rsplit("-", 1)[-1].strip()
|
||||
_add_character_lookup(simple_lookup, ambiguous_simple, simple_name, option)
|
||||
if not simple_lookup:
|
||||
return None
|
||||
return full_lookup, simple_lookup
|
||||
|
||||
|
||||
def _character_lookups_from_combat_avatar(
|
||||
data: Any,
|
||||
) -> tuple[dict[str, str], dict[str, str]] | None:
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
|
||||
full_lookup: dict[str, str] = {}
|
||||
simple_lookup: dict[str, str] = {}
|
||||
ambiguous_full: set[str] = set()
|
||||
ambiguous_simple: set[str] = set()
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
canonical = str(item.get("name") or "").strip()
|
||||
if not canonical:
|
||||
continue
|
||||
terms = [canonical]
|
||||
aliases = item.get("alias")
|
||||
if isinstance(aliases, list):
|
||||
terms.extend(str(alias).strip() for alias in aliases if str(alias).strip())
|
||||
for term in terms:
|
||||
_add_character_lookup(full_lookup, ambiguous_full, term, canonical)
|
||||
_add_character_lookup(simple_lookup, ambiguous_simple, term, canonical)
|
||||
if not simple_lookup:
|
||||
return None
|
||||
return full_lookup, simple_lookup
|
||||
|
||||
|
||||
def _load_character_options(work_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
|
||||
script_dir = work_dir / "User" / "JsScript" / "AutoSwitchRoles"
|
||||
settings_path = script_dir / "settings.json"
|
||||
avatar_path = script_dir / "combat_avatar.json"
|
||||
failures: list[str] = []
|
||||
|
||||
if settings_path.exists():
|
||||
try:
|
||||
settings_data = json.loads(settings_path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
failures.append(f"settings.json 读取失败: {exc}")
|
||||
else:
|
||||
lookups = _character_lookups_from_settings(settings_data)
|
||||
if lookups:
|
||||
return lookups
|
||||
failures.append("settings.json 未提供四个队员位置的 options")
|
||||
else:
|
||||
failures.append("缺少 settings.json")
|
||||
|
||||
if avatar_path.exists():
|
||||
try:
|
||||
avatar_data = json.loads(avatar_path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
failures.append(f"combat_avatar.json 读取失败: {exc}")
|
||||
else:
|
||||
lookups = _character_lookups_from_combat_avatar(avatar_data)
|
||||
if lookups:
|
||||
return lookups
|
||||
failures.append("combat_avatar.json 中没有可用角色")
|
||||
else:
|
||||
failures.append("缺少 combat_avatar.json")
|
||||
|
||||
raise DailyAutomationError(
|
||||
"AutoSwitchRoles 角色数据不可用,请安装或更新“配对界面切换角色”脚本: "
|
||||
+ ";".join(failures)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_member_token(
|
||||
token: str,
|
||||
full_lookup: dict[str, str],
|
||||
simple_lookup: dict[str, str],
|
||||
) -> str:
|
||||
key = _normalize_name(token)
|
||||
resolved = full_lookup.get(key) or simple_lookup.get(key)
|
||||
if not resolved:
|
||||
raise DailyAutomationError(f"未知或有歧义的角色名称: {token}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _split_contiguous_members(text: str, simple_lookup: dict[str, str]) -> list[str]:
|
||||
normalized = _normalize_name(text)
|
||||
candidates = sorted(simple_lookup, key=len, reverse=True)
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def walk(offset: int, slots: int) -> tuple[tuple[str, ...], ...]:
|
||||
if slots == 4:
|
||||
return ((),) if offset == len(normalized) else ()
|
||||
if offset >= len(normalized):
|
||||
return ()
|
||||
results: list[tuple[str, ...]] = []
|
||||
for candidate in candidates:
|
||||
if not normalized.startswith(candidate, offset):
|
||||
continue
|
||||
for remainder in walk(offset + len(candidate), slots + 1):
|
||||
results.append((candidate, *remainder))
|
||||
if len(results) >= 2:
|
||||
return tuple(results)
|
||||
return tuple(results)
|
||||
|
||||
segmentations = walk(0, 0)
|
||||
if not segmentations:
|
||||
raise DailyAutomationError("队员必须是4人,请使用空格、逗号、顿号或斜杠分隔")
|
||||
if len(segmentations) > 1:
|
||||
raise DailyAutomationError("连续角色名存在多种拆分方式,请使用空格分隔四名角色")
|
||||
return [simple_lookup[key] for key in segmentations[0]]
|
||||
|
||||
|
||||
def resolve_party_members(work_dir: str | Path, argument: str) -> tuple[list[str], list[str]]:
|
||||
text = str(argument or "").strip()
|
||||
if not text:
|
||||
raise DailyAutomationError("队员必须是4人")
|
||||
full_lookup, simple_lookup = _load_character_options(Path(work_dir))
|
||||
parts = [part for part in _MEMBER_SEPARATOR.split(text) if part]
|
||||
if len(parts) == 4:
|
||||
resolved = [
|
||||
_resolve_member_token(part, full_lookup, simple_lookup)
|
||||
for part in parts
|
||||
]
|
||||
else:
|
||||
resolved = _split_contiguous_members(text, simple_lookup)
|
||||
if len(resolved) != 4:
|
||||
raise DailyAutomationError("队员必须是4人")
|
||||
if len(set(resolved)) != 4:
|
||||
raise DailyAutomationError("四名队员不能重复")
|
||||
display_names = [value.rsplit("-", 1)[-1] for value in resolved]
|
||||
return resolved, display_names
|
||||
|
||||
|
||||
def prepare_edit_party_update(
|
||||
work_dir: str | Path,
|
||||
argument: str,
|
||||
) -> tuple[JsonUpdate, tuple[str, ...]]:
|
||||
resolved, display_names = resolve_party_members(work_dir, argument)
|
||||
update = _prepare_script_group_update(
|
||||
work_dir,
|
||||
"修改队员",
|
||||
"AutoSwitchRoles",
|
||||
{f"position{index + 1}": value for index, value in enumerate(resolved)},
|
||||
)
|
||||
return update, tuple(display_names)
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def apply_json_updates(updates: Iterable[JsonUpdate]) -> None:
|
||||
seen: set[Path] = set()
|
||||
for update in updates:
|
||||
path = Path(update.path)
|
||||
resolved = path.resolve()
|
||||
if resolved in seen:
|
||||
raise DailyAutomationError(f"同一配置文件被重复更新: {path}")
|
||||
seen.add(resolved)
|
||||
_write_json_atomic(path, update.data)
|
||||
@@ -0,0 +1,601 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import http.cookies
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg
|
||||
Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71
|
||||
nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40
|
||||
JNrRuoEUXpabUzGB8QIDAQAB
|
||||
-----END PUBLIC KEY-----"""
|
||||
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
_QR_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
|
||||
_QR_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
|
||||
_QR_HEADERS = {
|
||||
"Referer": "https://www.bilibili.com/",
|
||||
"Origin": "https://www.bilibili.com",
|
||||
}
|
||||
|
||||
|
||||
class _DataBlob(ctypes.Structure):
|
||||
_fields_ = [("cbData", ctypes.wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
||||
|
||||
|
||||
def _blob(data: bytes) -> tuple[_DataBlob, Any]:
|
||||
buffer = ctypes.create_string_buffer(data)
|
||||
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))), buffer
|
||||
|
||||
|
||||
def _dpapi_encrypt(value: str) -> str:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
|
||||
source, source_buffer = _blob(value.encode("utf-8"))
|
||||
entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
|
||||
output = _DataBlob()
|
||||
ok = ctypes.windll.crypt32.CryptProtectData(
|
||||
ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
|
||||
ctypes.byref(output),
|
||||
)
|
||||
_ = source_buffer, entropy_buffer
|
||||
if not ok:
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
encrypted = ctypes.string_at(output.pbData, output.cbData)
|
||||
return base64.b64encode(encrypted).decode("ascii")
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree(output.pbData)
|
||||
|
||||
|
||||
def _dpapi_decrypt(value: str) -> str:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
|
||||
source, source_buffer = _blob(base64.b64decode(value))
|
||||
entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
|
||||
output = _DataBlob()
|
||||
ok = ctypes.windll.crypt32.CryptUnprotectData(
|
||||
ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
|
||||
ctypes.byref(output),
|
||||
)
|
||||
_ = source_buffer, entropy_buffer
|
||||
if not ok:
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return ctypes.string_at(output.pbData, output.cbData).decode("utf-8")
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree(output.pbData)
|
||||
|
||||
|
||||
class BilibiliCredentialStore:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def save_refresh_token(self, refresh_token: str) -> None:
|
||||
token = str(refresh_token or "").strip()
|
||||
if not token:
|
||||
raise ValueError("refresh_token 不能为空")
|
||||
payload = {
|
||||
"version": 1,
|
||||
"provider": "windows_dpapi_current_user",
|
||||
"refresh_token_protected": _dpapi_encrypt(token),
|
||||
}
|
||||
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 handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
tmp.replace(self.path)
|
||||
|
||||
def load_refresh_token(self) -> str:
|
||||
if not self.path.exists():
|
||||
return ""
|
||||
with open(self.path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
protected = str(payload.get("refresh_token_protected") or "")
|
||||
return _dpapi_decrypt(protected) if protected else ""
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
if not self.path.exists():
|
||||
return False
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
return bool(str(payload.get("refresh_token_protected") or ""))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_der_length(data: bytes, offset: int) -> tuple[int, int]:
|
||||
first = data[offset]
|
||||
offset += 1
|
||||
if first < 0x80:
|
||||
return first, offset
|
||||
count = first & 0x7F
|
||||
return int.from_bytes(data[offset:offset + count], "big"), offset + count
|
||||
|
||||
|
||||
def _read_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]:
|
||||
tag = data[offset]
|
||||
if expected_tag is not None and tag != expected_tag:
|
||||
raise ValueError(f"DER tag 不匹配: expected={expected_tag:#x}, actual={tag:#x}")
|
||||
length, content_offset = _read_der_length(data, offset + 1)
|
||||
end = content_offset + length
|
||||
return tag, data[content_offset:end], end
|
||||
|
||||
|
||||
def _public_numbers() -> tuple[int, int]:
|
||||
body = "".join(line for line in _PUBLIC_KEY_PEM.splitlines() if not line.startswith("-----"))
|
||||
der = base64.b64decode(body)
|
||||
_, spki, _ = _read_der_tlv(der, 0, 0x30)
|
||||
_, _, offset = _read_der_tlv(spki, 0, 0x30)
|
||||
_, bit_string, _ = _read_der_tlv(spki, offset, 0x03)
|
||||
_, rsa_key, _ = _read_der_tlv(bit_string[1:], 0, 0x30)
|
||||
_, modulus_bytes, rsa_offset = _read_der_tlv(rsa_key, 0, 0x02)
|
||||
_, exponent_bytes, _ = _read_der_tlv(rsa_key, rsa_offset, 0x02)
|
||||
return int.from_bytes(modulus_bytes, "big"), int.from_bytes(exponent_bytes, "big")
|
||||
|
||||
|
||||
def _mgf1(seed: bytes, length: int) -> bytes:
|
||||
result = bytearray()
|
||||
counter = 0
|
||||
while len(result) < length:
|
||||
result.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
|
||||
counter += 1
|
||||
return bytes(result[:length])
|
||||
|
||||
|
||||
def _rsa_oaep_sha256_encrypt(message: bytes) -> str:
|
||||
modulus, exponent = _public_numbers()
|
||||
key_size = (modulus.bit_length() + 7) // 8
|
||||
digest_size = hashlib.sha256().digest_size
|
||||
if len(message) > key_size - 2 * digest_size - 2:
|
||||
raise ValueError("待加密内容过长")
|
||||
label_hash = hashlib.sha256(b"").digest()
|
||||
padding = b"\x00" * (key_size - len(message) - 2 * digest_size - 2)
|
||||
data_block = label_hash + padding + b"\x01" + message
|
||||
seed = secrets.token_bytes(digest_size)
|
||||
data_mask = _mgf1(seed, key_size - digest_size - 1)
|
||||
masked_data = bytes(left ^ right for left, right in zip(data_block, data_mask))
|
||||
seed_mask = _mgf1(masked_data, digest_size)
|
||||
masked_seed = bytes(left ^ right for left, right in zip(seed, seed_mask))
|
||||
encoded = b"\x00" + masked_seed + masked_data
|
||||
encrypted = pow(int.from_bytes(encoded, "big"), exponent, modulus)
|
||||
return encrypted.to_bytes(key_size, "big").hex()
|
||||
|
||||
|
||||
def _parse_cookie(cookie_text: str) -> dict[str, str]:
|
||||
parsed = http.cookies.SimpleCookie()
|
||||
parsed.load(str(cookie_text or "").replace("; ", ";"))
|
||||
return {name: morsel.value for name, morsel in parsed.items()}
|
||||
|
||||
|
||||
def _cookie_header(values: dict[str, str]) -> str:
|
||||
return "; ".join(f"{name}={value}" for name, value in values.items() if value)
|
||||
|
||||
|
||||
def _request_json(url: str, *, cookie: str = "", data: dict[str, str] | None = None,
|
||||
opener: urllib.request.OpenerDirector | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
retries: int = 0) -> tuple[dict, Any]:
|
||||
body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
|
||||
request = urllib.request.Request(url, data=body, method="POST" if body is not None else "GET")
|
||||
request.add_header("User-Agent", _USER_AGENT)
|
||||
for name, value in (headers or {}).items():
|
||||
request.add_header(name, value)
|
||||
if cookie:
|
||||
request.add_header("Cookie", cookie)
|
||||
if body is not None:
|
||||
request.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
retry_count = max(0, int(retries))
|
||||
for attempt in range(retry_count + 1):
|
||||
try:
|
||||
response = (opener or urllib.request.build_opener()).open(request, timeout=15)
|
||||
return json.loads(response.read().decode("utf-8")), response
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
|
||||
if attempt >= retry_count:
|
||||
raise RuntimeError("连接B站登录服务失败,请稍后重试") from exc
|
||||
time.sleep(0.4 * (attempt + 1))
|
||||
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def _seed_cookie_jar(jar: http.cookiejar.CookieJar, values: dict[str, str]) -> None:
|
||||
for name, value in values.items():
|
||||
jar.set_cookie(http.cookiejar.Cookie(
|
||||
version=0, name=name, value=value, port=None, port_specified=False,
|
||||
domain=".bilibili.com", domain_specified=True, domain_initial_dot=True,
|
||||
path="/", path_specified=True, secure=False, expires=None, discard=True,
|
||||
comment=None, comment_url=None, rest={}, rfc2109=False,
|
||||
))
|
||||
|
||||
|
||||
def _jar_values(jar: http.cookiejar.CookieJar) -> dict[str, str]:
|
||||
return {cookie.name: cookie.value for cookie in jar}
|
||||
|
||||
|
||||
def _login_url_cookie_values(url: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
query = urllib.parse.urlparse(str(url or "")).query
|
||||
for item in query.split("&"):
|
||||
raw_name, separator, raw_value = item.partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
name = urllib.parse.unquote_plus(raw_name)
|
||||
if name in {"SESSDATA", "bili_jct", "DedeUserID", "DedeUserID__ckMd5", "sid", "buvid3"}:
|
||||
values[name] = raw_value
|
||||
return values
|
||||
|
||||
|
||||
def _render_qr_png(content: str) -> bytes:
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("缺少 qrcode 依赖,请重新安装 requirements.txt") from exc
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=3,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class BilibiliQrLogin:
|
||||
"""服务端持有二维码密钥和 CookieJar,前端只获取二维码图片与状态。"""
|
||||
|
||||
_STATUS_MESSAGES = {
|
||||
"idle": "尚未开始扫码登录",
|
||||
"awaiting_scan": "请使用哔哩哔哩客户端扫码",
|
||||
"awaiting_confirm": "已扫码,请在手机上确认登录",
|
||||
"completed": "登录成功,Cookie 与刷新凭据已更新",
|
||||
"expired": "二维码已过期,请重新生成",
|
||||
"failed": "扫码登录失败",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credential_store: BilibiliCredentialStore,
|
||||
update_cookie: Callable[[dict[str, str]], None],
|
||||
logger: logging.Logger,
|
||||
on_logged_in: Callable[[], Any] | None = None,
|
||||
ttl_seconds: int = 180,
|
||||
):
|
||||
self.credential_store = credential_store
|
||||
self.update_cookie = update_cookie
|
||||
self.logger = logger
|
||||
self.on_logged_in = on_logged_in
|
||||
self.ttl_seconds = max(60, int(ttl_seconds))
|
||||
self._lock = asyncio.Lock()
|
||||
self._session: dict[str, Any] | None = None
|
||||
|
||||
def _snapshot(self) -> dict[str, Any]:
|
||||
session = self._session or {}
|
||||
state = str(session.get("state") or "idle")
|
||||
expires_at = float(session.get("expires_at") or 0)
|
||||
expires_in = max(0, int(expires_at - time.time())) if expires_at else 0
|
||||
return {
|
||||
"success": True,
|
||||
"state": state,
|
||||
"message": str(session.get("message") or self._STATUS_MESSAGES.get(state, "")),
|
||||
"expires_in": expires_in,
|
||||
"has_qr_image": state in {"awaiting_scan", "awaiting_confirm"} and expires_in > 0,
|
||||
"credential_configured": self.credential_store.is_configured(),
|
||||
"account": session.get("account"),
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return self._snapshot()
|
||||
|
||||
def _start_sync(self) -> dict[str, Any]:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
payload, _ = _request_json(
|
||||
_QR_GENERATE_URL,
|
||||
opener=opener,
|
||||
headers=_QR_HEADERS,
|
||||
retries=2,
|
||||
)
|
||||
if payload.get("code") != 0:
|
||||
raise RuntimeError(f"B站二维码申请失败 code={payload.get('code')}")
|
||||
data = payload.get("data") or {}
|
||||
qr_url = str(data.get("url") or "").strip()
|
||||
qr_key = str(data.get("qrcode_key") or "").strip()
|
||||
if not qr_url or not qr_key:
|
||||
raise RuntimeError("B站二维码响应缺少必要字段")
|
||||
now = time.time()
|
||||
return {
|
||||
"state": "awaiting_scan",
|
||||
"message": self._STATUS_MESSAGES["awaiting_scan"],
|
||||
"created_at": now,
|
||||
"expires_at": now + self.ttl_seconds,
|
||||
"qr_url": qr_url,
|
||||
"qr_key": qr_key,
|
||||
"jar": jar,
|
||||
"opener": opener,
|
||||
"account": None,
|
||||
}
|
||||
|
||||
async def start(self) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
try:
|
||||
self._session = await asyncio.to_thread(self._start_sync)
|
||||
except Exception as exc:
|
||||
self._session = {
|
||||
"state": "failed",
|
||||
"message": str(exc) or type(exc).__name__,
|
||||
"expires_at": 0,
|
||||
}
|
||||
self.logger.warning("[B站扫码登录] 二维码申请失败: %s", type(exc).__name__)
|
||||
return self._snapshot()
|
||||
self.logger.info("[B站扫码登录] 二维码已生成,等待扫码")
|
||||
return self._snapshot()
|
||||
|
||||
def _poll_sync(self, session: dict[str, Any]) -> dict[str, Any]:
|
||||
url = _QR_POLL_URL + "?" + urllib.parse.urlencode({"qrcode_key": session["qr_key"]})
|
||||
payload, _ = _request_json(
|
||||
url,
|
||||
opener=session["opener"],
|
||||
headers=_QR_HEADERS,
|
||||
retries=2,
|
||||
)
|
||||
if payload.get("code") != 0:
|
||||
return {"state": "failed", "message": f"B站扫码状态查询失败 code={payload.get('code')}"}
|
||||
|
||||
data = payload.get("data") or {}
|
||||
status_code = int(data.get("code") or 0)
|
||||
if status_code == 86101:
|
||||
return {"state": "awaiting_scan", "message": self._STATUS_MESSAGES["awaiting_scan"]}
|
||||
if status_code == 86090:
|
||||
return {"state": "awaiting_confirm", "message": self._STATUS_MESSAGES["awaiting_confirm"]}
|
||||
if status_code == 86038:
|
||||
return {"state": "expired", "message": self._STATUS_MESSAGES["expired"]}
|
||||
if status_code != 0:
|
||||
return {"state": "failed", "message": str(data.get("message") or f"扫码失败 code={status_code}")}
|
||||
|
||||
refresh_token = str(data.get("refresh_token") or "").strip()
|
||||
if not refresh_token:
|
||||
return {"state": "failed", "message": "扫码成功响应缺少 refresh_token"}
|
||||
cookie_values = _jar_values(session["jar"])
|
||||
for name, value in _login_url_cookie_values(str(data.get("url") or "")).items():
|
||||
cookie_values.setdefault(name, value)
|
||||
if not cookie_values.get("SESSDATA") or not cookie_values.get("bili_jct"):
|
||||
return {"state": "failed", "message": "扫码成功但登录 Cookie 不完整"}
|
||||
|
||||
cookie = _cookie_header(cookie_values)
|
||||
nav, _ = _request_json(
|
||||
"https://api.bilibili.com/x/web-interface/nav",
|
||||
cookie=cookie,
|
||||
headers={"Referer": "https://www.bilibili.com/"},
|
||||
retries=2,
|
||||
)
|
||||
nav_data = nav.get("data") or {}
|
||||
if nav.get("code") != 0 or not bool(nav_data.get("isLogin")):
|
||||
return {"state": "failed", "message": "扫码 Cookie 登录验证失败"}
|
||||
|
||||
self.credential_store.save_refresh_token(refresh_token)
|
||||
self.update_cookie(cookie_values)
|
||||
return {
|
||||
"state": "completed",
|
||||
"message": self._STATUS_MESSAGES["completed"],
|
||||
"account": {
|
||||
"mid": str(nav_data.get("mid") or ""),
|
||||
"uname": str(nav_data.get("uname") or "B站账号"),
|
||||
},
|
||||
}
|
||||
|
||||
async def poll(self) -> dict[str, Any]:
|
||||
callback_needed = False
|
||||
async with self._lock:
|
||||
if not self._session:
|
||||
return self._snapshot()
|
||||
state = str(self._session.get("state") or "idle")
|
||||
if state in {"completed", "expired", "failed"}:
|
||||
return self._snapshot()
|
||||
if time.time() >= float(self._session.get("expires_at") or 0):
|
||||
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
||||
return self._snapshot()
|
||||
try:
|
||||
result = await asyncio.to_thread(self._poll_sync, self._session)
|
||||
except Exception as exc:
|
||||
self.logger.warning("[B站扫码登录] 状态查询异常: %s", type(exc).__name__)
|
||||
self._session.update(
|
||||
state="failed",
|
||||
message=str(exc) or f"扫码状态查询异常: {type(exc).__name__}",
|
||||
)
|
||||
return self._snapshot()
|
||||
previous_state = state
|
||||
self._session.update(result)
|
||||
callback_needed = previous_state != "completed" and result.get("state") == "completed"
|
||||
snapshot = self._snapshot()
|
||||
|
||||
if callback_needed:
|
||||
self.logger.info("[B站扫码登录] 登录成功,Cookie 与刷新凭据已更新")
|
||||
if self.on_logged_in:
|
||||
callback_result = self.on_logged_in()
|
||||
if asyncio.iscoroutine(callback_result):
|
||||
await callback_result
|
||||
return snapshot
|
||||
|
||||
async def qr_png(self) -> bytes:
|
||||
async with self._lock:
|
||||
if not self._session or self._session.get("state") not in {"awaiting_scan", "awaiting_confirm"}:
|
||||
raise RuntimeError("当前没有可用的登录二维码")
|
||||
if time.time() >= float(self._session.get("expires_at") or 0):
|
||||
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
||||
raise RuntimeError("登录二维码已过期")
|
||||
content = str(self._session.get("qr_url") or "")
|
||||
return await asyncio.to_thread(_render_qr_png, content)
|
||||
|
||||
|
||||
class BilibiliCookieRefresher:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credential_store: BilibiliCredentialStore,
|
||||
get_cookie: Callable[[], str],
|
||||
update_cookie: Callable[[dict[str, str]], None],
|
||||
logger: logging.Logger,
|
||||
check_interval_seconds: int = 6 * 60 * 60,
|
||||
on_refreshed: Callable[[], Any] | None = None,
|
||||
is_enabled: Callable[[], bool] | None = None,
|
||||
get_check_interval_seconds: Callable[[], int] | None = None,
|
||||
):
|
||||
self.credential_store = credential_store
|
||||
self.get_cookie = get_cookie
|
||||
self.update_cookie = update_cookie
|
||||
self.logger = logger
|
||||
self.check_interval_seconds = max(3600, int(check_interval_seconds))
|
||||
self.on_refreshed = on_refreshed
|
||||
self.is_enabled = is_enabled or (lambda: True)
|
||||
self.get_check_interval_seconds = get_check_interval_seconds
|
||||
self._stop = False
|
||||
self._wake = asyncio.Event()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
self._wake.set()
|
||||
|
||||
def wake(self) -> None:
|
||||
self._wake.set()
|
||||
|
||||
def _current_interval(self) -> int:
|
||||
if not self.get_check_interval_seconds:
|
||||
return self.check_interval_seconds
|
||||
try:
|
||||
return max(3600, int(self.get_check_interval_seconds()))
|
||||
except (TypeError, ValueError):
|
||||
return self.check_interval_seconds
|
||||
|
||||
def _check_and_refresh_sync(self) -> dict[str, Any]:
|
||||
refresh_token = self.credential_store.load_refresh_token()
|
||||
if not refresh_token:
|
||||
return {"status": "disabled", "message": "未配置刷新令牌"}
|
||||
current_cookie = self.get_cookie()
|
||||
current_values = _parse_cookie(current_cookie)
|
||||
csrf = current_values.get("bili_jct", "")
|
||||
if not current_values.get("SESSDATA") or not csrf:
|
||||
return {"status": "failed", "message": "当前 Cookie 缺少 SESSDATA 或 bili_jct"}
|
||||
|
||||
info, _ = _request_json(
|
||||
"https://passport.bilibili.com/x/passport-login/web/cookie/info?" +
|
||||
urllib.parse.urlencode({"csrf": csrf}),
|
||||
cookie=current_cookie,
|
||||
retries=2,
|
||||
)
|
||||
if info.get("code") != 0:
|
||||
return {"status": "failed", "message": f"登录状态检查失败 code={info.get('code')}"}
|
||||
if not bool((info.get("data") or {}).get("refresh")):
|
||||
return {"status": "valid", "message": "Cookie 当前无需刷新"}
|
||||
|
||||
timestamp = str((info.get("data") or {}).get("timestamp") or "")
|
||||
correspond_path = _rsa_oaep_sha256_encrypt(f"refresh_{timestamp}".encode("utf-8"))
|
||||
request = urllib.request.Request(
|
||||
f"https://www.bilibili.com/correspond/1/{correspond_path}",
|
||||
headers={"User-Agent": _USER_AGENT, "Cookie": current_cookie},
|
||||
)
|
||||
html = urllib.request.urlopen(request, timeout=15).read().decode("utf-8", errors="replace")
|
||||
match = __import__("re").search(r'<div\s+id=["\']1-name["\']>([^<]+)</div>', html)
|
||||
if not match:
|
||||
return {"status": "failed", "message": "未获取到 refresh_csrf"}
|
||||
refresh_csrf = match.group(1).strip()
|
||||
|
||||
old_refresh_token = refresh_token
|
||||
jar = http.cookiejar.CookieJar()
|
||||
_seed_cookie_jar(jar, current_values)
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
refreshed, _ = _request_json(
|
||||
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
|
||||
data={
|
||||
"csrf": csrf,
|
||||
"refresh_csrf": refresh_csrf,
|
||||
"source": "main_web",
|
||||
"refresh_token": old_refresh_token,
|
||||
},
|
||||
opener=opener,
|
||||
)
|
||||
if refreshed.get("code") != 0:
|
||||
return {"status": "failed", "message": f"Cookie 刷新失败 code={refreshed.get('code')}"}
|
||||
new_refresh_token = str((refreshed.get("data") or {}).get("refresh_token") or "")
|
||||
if not new_refresh_token:
|
||||
return {"status": "failed", "message": "刷新响应缺少新 refresh_token"}
|
||||
new_values = dict(current_values)
|
||||
new_values.update(_jar_values(jar))
|
||||
new_csrf = new_values.get("bili_jct", "")
|
||||
new_cookie = _cookie_header(new_values)
|
||||
|
||||
confirmed, _ = _request_json(
|
||||
"https://passport.bilibili.com/x/passport-login/web/confirm/refresh",
|
||||
cookie=new_cookie,
|
||||
data={"csrf": new_csrf, "refresh_token": old_refresh_token},
|
||||
)
|
||||
if confirmed.get("code") != 0:
|
||||
return {"status": "failed", "message": f"刷新确认失败 code={confirmed.get('code')}"}
|
||||
|
||||
nav, _ = _request_json(
|
||||
"https://api.bilibili.com/x/web-interface/nav",
|
||||
cookie=new_cookie,
|
||||
retries=2,
|
||||
)
|
||||
if nav.get("code") != 0 or not bool((nav.get("data") or {}).get("isLogin")):
|
||||
return {"status": "failed", "message": "新 Cookie 登录验证失败"}
|
||||
self.credential_store.save_refresh_token(new_refresh_token)
|
||||
self.update_cookie(new_values)
|
||||
return {"status": "refreshed", "message": "Cookie 已刷新并验证"}
|
||||
|
||||
async def check_once(self) -> dict[str, Any]:
|
||||
try:
|
||||
result = await asyncio.to_thread(self._check_and_refresh_sync)
|
||||
except Exception as exc:
|
||||
self.logger.warning("[B站凭据] 自动检查异常: %s", type(exc).__name__)
|
||||
return {"status": "failed", "message": type(exc).__name__}
|
||||
status = result.get("status")
|
||||
if status == "refreshed":
|
||||
self.logger.info("[B站凭据] Cookie 已自动续期并完成登录验证")
|
||||
if self.on_refreshed:
|
||||
callback_result = self.on_refreshed()
|
||||
if asyncio.iscoroutine(callback_result):
|
||||
await callback_result
|
||||
elif status == "valid":
|
||||
self.logger.info("[B站凭据] Cookie 有效,当前无需续期")
|
||||
elif status == "disabled":
|
||||
self.logger.warning("[B站凭据] 自动续期未启用:未配置刷新令牌")
|
||||
else:
|
||||
self.logger.warning("[B站凭据] 自动续期失败:%s", result.get("message", "未知错误"))
|
||||
return result
|
||||
|
||||
async def run(self) -> None:
|
||||
while not self._stop:
|
||||
if self.is_enabled():
|
||||
await self.check_once()
|
||||
try:
|
||||
await asyncio.wait_for(self._wake.wait(), timeout=self._current_interval())
|
||||
self._wake.clear()
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
@@ -0,0 +1 @@
|
||||
"""Core helpers for the live streaming app."""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Runtime path helpers.
|
||||
|
||||
All mutable runtime data is resolved from the executable directory when the
|
||||
program is frozen, and from the project root while running from source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def app_root() -> Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
APP_ROOT = app_root()
|
||||
CONFIG_DIR = APP_ROOT / "config"
|
||||
DATA_DIR = APP_ROOT / "data"
|
||||
WEB_DIR = APP_ROOT / "web"
|
||||
LOG_DIR = APP_ROOT / "logs"
|
||||
INTEGRATIONS_DIR = APP_ROOT / "integrations"
|
||||
VENDOR_DIR = APP_ROOT / "vendor"
|
||||
DOTS_TTS_SRC = VENDOR_DIR / "dots.tts-main" / "src"
|
||||
|
||||
|
||||
def project_path(value: str | os.PathLike, *, base: Path | None = None) -> Path:
|
||||
path = Path(value)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (base or APP_ROOT) / path
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
for path in (CONFIG_DIR, DATA_DIR, WEB_DIR, LOG_DIR):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
BetterGI 弹幕联动主程序
|
||||
========================
|
||||
监听 B 站直播间弹幕 -> 关键词匹配 -> 调用 BetterGI.exe --startGroups 执行配置组
|
||||
|
||||
依赖: pip install websockets brotli
|
||||
运行: python danmu_bettergi.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
from core.runtime_paths import APP_ROOT as PROJECT_ROOT, CONFIG_DIR, LOG_DIR, ensure_runtime_dirs, project_path
|
||||
|
||||
try:
|
||||
import websockets
|
||||
import brotli
|
||||
except ImportError:
|
||||
print("缺少依赖,请先运行: pip install websockets brotli")
|
||||
sys.exit(1)
|
||||
|
||||
# ============== B站直播弹幕协议常量 ==============
|
||||
HEADER_LEN = 16
|
||||
OP_HEARTBEAT = 2 # 心跳请求
|
||||
OP_HEARTBEAT_REPLY = 3 # 心跳响应(人气值)
|
||||
OP_MESSAGE = 5 # 业务消息
|
||||
OP_AUTH = 7 # 认证请求
|
||||
OP_AUTH_REPLY = 8 # 认证响应
|
||||
PROTO_JSON = 0 # 明文JSON
|
||||
PROTO_ZLIB = 2 # zlib压缩
|
||||
PROTO_BROTLI = 3 # brotli压缩
|
||||
|
||||
|
||||
# ============== 弹幕服务器发现 ==============
|
||||
def get_danmu_server(room_id: int) -> dict:
|
||||
"""通过B站API获取弹幕服务器地址和token。优先使用host_server_list中的新服务器。"""
|
||||
url = f"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={room_id}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "Mozilla/5.0")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())["data"]
|
||||
token = data["token"]
|
||||
host_list = data.get("host_server_list", [])
|
||||
if host_list:
|
||||
entry = host_list[0]
|
||||
host = entry["host"]
|
||||
port = entry.get("wss_port", 443)
|
||||
else:
|
||||
host = data["host"]
|
||||
port = data.get("wss_port", 443)
|
||||
return {
|
||||
"ws_url": f"wss://{host}:{port}/sub",
|
||||
"token": token,
|
||||
"host": host,
|
||||
"port": port,
|
||||
}
|
||||
|
||||
|
||||
def get_buvid3(sessdata: str = "") -> str:
|
||||
"""通过B站finger/spi接口获取buvid3 (2024+协议认证必需)。"""
|
||||
url = "https://api.bilibili.com/x/frontend/finger/spi"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "Mozilla/5.0")
|
||||
if sessdata:
|
||||
req.add_header("Cookie", f"SESSDATA={sessdata}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["b_3"]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ============== 配置管理 ==============
|
||||
class Config:
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
self.data = {}
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
self.data = json.load(f)
|
||||
|
||||
@property
|
||||
def room_id(self) -> int:
|
||||
return int(self.data["bilibili"]["room_id"])
|
||||
|
||||
@property
|
||||
def sessdata(self) -> str:
|
||||
return self.data["bilibili"].get("sessdata", "")
|
||||
|
||||
@property
|
||||
def bettergi_exe(self) -> str:
|
||||
return self.data["bettergi"]["exe_path"]
|
||||
|
||||
@property
|
||||
def bettergi_work_dir(self) -> str:
|
||||
wd = self.data["bettergi"].get("work_dir", "")
|
||||
return wd if wd else str(Path(self.bettergi_exe).parent)
|
||||
|
||||
@property
|
||||
def default_cooldown(self) -> int:
|
||||
return int(self.data["global"].get("default_cooldown", 30))
|
||||
|
||||
@property
|
||||
def admin_uids(self) -> set:
|
||||
return set(int(x) for x in self.data["global"].get("admin_uids", []))
|
||||
|
||||
@property
|
||||
def rules(self) -> list:
|
||||
return self.data.get("rules", [])
|
||||
|
||||
@property
|
||||
def restart_mode(self) -> str:
|
||||
"""重启模式: gentle(温和,跳过) / aggressive(激进,先杀再启)"""
|
||||
return self.data["global"].get("restart_mode", "gentle")
|
||||
|
||||
|
||||
# ============== 冷却管理 ==============
|
||||
class CooldownManager:
|
||||
"""按规则记录最后触发时间,防止同一指令被弹幕刷屏重复触发。"""
|
||||
|
||||
def __init__(self):
|
||||
self._last_fire: dict = {} # keyword -> timestamp
|
||||
|
||||
def can_fire(self, keyword: str, cooldown: int) -> bool:
|
||||
now = time.time()
|
||||
last = self._last_fire.get(keyword, 0)
|
||||
return (now - last) >= cooldown
|
||||
|
||||
def mark_fired(self, keyword: str):
|
||||
self._last_fire[keyword] = time.time()
|
||||
|
||||
|
||||
# ============== BetterGI 调用 ==============
|
||||
class BetterGIRunner:
|
||||
"""封装 BetterGI.exe --startGroups 调用。"""
|
||||
|
||||
def __init__(self, exe_path: str, work_dir: str, logger: logging.Logger,
|
||||
restart_mode: str = "gentle"):
|
||||
self.exe_path = exe_path
|
||||
self.work_dir = work_dir
|
||||
self.logger = logger
|
||||
self.restart_mode = restart_mode # gentle / aggressive
|
||||
self._busy = False # 标记是否正在执行任务
|
||||
self._lock = asyncio.Lock()
|
||||
# 缓存 cancelTaskHotkey (从 BetterGI Config.json 读)
|
||||
self._cancel_hotkey = None
|
||||
self._load_cancel_hotkey()
|
||||
|
||||
def _load_cancel_hotkey(self):
|
||||
"""从 BetterGI 的 Config.json 读取取消任务热键。"""
|
||||
try:
|
||||
import os
|
||||
cfg_path = os.path.join(self.work_dir, "User", "Config.json")
|
||||
if os.path.exists(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
bgi_cfg = json.load(f)
|
||||
hk = bgi_cfg.get("hotKeyConfig", {}).get("cancelTaskHotkey", "")
|
||||
if hk:
|
||||
self._cancel_hotkey = hk
|
||||
self.logger.info(f"已读取 BetterGI 取消任务热键: {hk}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"读取 cancelTaskHotkey 失败(不影响使用): {e}")
|
||||
|
||||
async def _stop_bgi_gracefully(self):
|
||||
"""优雅停止 BetterGI: 先按取消热键,再 taskkill 兜底。"""
|
||||
# ① 模拟按取消热键 (让 BGI 内部收尾,保存进度)
|
||||
if self._cancel_hotkey:
|
||||
self.logger.info(f"[激进] 按取消热键: {self._cancel_hotkey}")
|
||||
try:
|
||||
await self._send_key(self._cancel_hotkey)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"[激进] 按热键失败: {e}")
|
||||
# 给 BetterGI 5 秒收尾时间
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# ② taskkill 强杀兜底
|
||||
self.logger.info("[激进] taskkill /F /IM BetterGI.exe")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"taskkill", "/F", "/IM", "BetterGI.exe",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
# CREATE_NO_WINDOW = 0x08000000,避免弹黑窗
|
||||
creationflags=0x08000000,
|
||||
)
|
||||
await proc.communicate()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"[激进] taskkill 执行: {e}")
|
||||
# 再等 1 秒让进程完全退出
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _send_key(self, key: str):
|
||||
"""模拟按键。优先用 pywin32,其次 keyboard 库,都没有则跳过。"""
|
||||
# 简单映射: BetterGI 的热键名通常是 "F9" "Ctrl+P" 这种
|
||||
# 优先尝试 pywin32 (最稳定,不弹窗)
|
||||
try:
|
||||
import win32api
|
||||
import win32con
|
||||
# 简单支持单键 (F1-F24, 字母, 数字)
|
||||
vk_map = {
|
||||
"F1": win32con.VK_F1, "F2": win32con.VK_F2, "F3": win32con.VK_F3,
|
||||
"F4": win32con.VK_F4, "F5": win32con.VK_F5, "F6": win32con.VK_F6,
|
||||
"F7": win32con.VK_F7, "F8": win32con.VK_F8, "F9": win32con.VK_F9,
|
||||
"F10": win32con.VK_F10, "F11": win32con.VK_F11, "F12": win32con.VK_F12,
|
||||
"ESC": win32con.VK_ESCAPE, "ESCAPE": win32con.VK_ESCAPE,
|
||||
}
|
||||
# 处理 Ctrl+X Shift+X 这种组合键
|
||||
parts = key.replace("+", " ").split()
|
||||
main_key = parts[-1].upper()
|
||||
ctrl = "CTRL" in [p.upper() for p in parts[:-1]]
|
||||
shift = "SHIFT" in [p.upper() for p in parts[:-1]]
|
||||
alt = "ALT" in [p.upper() for p in parts[:-1]]
|
||||
|
||||
vk = vk_map.get(main_key)
|
||||
if vk is None and len(main_key) == 1:
|
||||
vk = ord(main_key.upper()) # 字母键
|
||||
|
||||
if vk is None:
|
||||
self.logger.warning(f"[激进] 不支持的热键: {key}, 跳过热键直接 taskkill")
|
||||
return
|
||||
|
||||
if ctrl:
|
||||
win32api.keybd_event(win32con.VK_CONTROL, 0, 0, 0)
|
||||
if shift:
|
||||
win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
|
||||
if alt:
|
||||
win32api.keybd_event(win32con.VK_MENU, 0, 0, 0)
|
||||
|
||||
win32api.keybd_event(vk, 0, 0, 0) # 按下
|
||||
win32api.keybd_event(vk, 0, win32con.KEYEVENTF_KEYUP, 0) # 抬起
|
||||
|
||||
if ctrl:
|
||||
win32api.keybd_event(win32con.VK_CONTROL, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
if shift:
|
||||
win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
if alt:
|
||||
win32api.keybd_event(win32con.VK_MENU, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
|
||||
self.logger.info(f"[激进] 已模拟按键: {key}")
|
||||
except ImportError:
|
||||
self.logger.warning(
|
||||
"[激进] 未安装 pywin32, 无法模拟热键。"
|
||||
"可运行: pip install pywin32 (可选,不装则直接 taskkill)"
|
||||
)
|
||||
|
||||
async def run_groups(self, groups: list) -> bool:
|
||||
"""异步启动配置组。"""
|
||||
async with self._lock:
|
||||
if self._busy:
|
||||
if self.restart_mode == "aggressive":
|
||||
self.logger.warning(
|
||||
f"[激进模式] BetterGI 正在执行任务,先杀再启: {groups}"
|
||||
)
|
||||
# 释放锁,执行停止 (停止可能耗时,不持有锁)
|
||||
self._busy = False
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"[温和模式] BetterGI 正在执行任务,跳过本次触发: {groups}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self._busy = True
|
||||
|
||||
# 激进模式: 先停止旧任务
|
||||
if self.restart_mode == "aggressive":
|
||||
await self._stop_bgi_gracefully()
|
||||
async with self._lock:
|
||||
self._busy = True
|
||||
|
||||
try:
|
||||
cmd = [self.exe_path, "--startGroups"] + groups
|
||||
self.logger.info(f"调用 BetterGI: {' '.join(cmd)}")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=self.work_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode in (0, 553):
|
||||
self.logger.info(f"BetterGI 启动成功: {groups} (rc={proc.returncode})")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(
|
||||
f"BetterGI 启动失败 rc={proc.returncode}: "
|
||||
f"{stderr.decode('gbk', errors='replace')}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.exception(f"调用 BetterGI 异常: {e}")
|
||||
return False
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
|
||||
# ============== 弹幕匹配引擎 ==============
|
||||
class DanmuMatcher:
|
||||
"""把弹幕文本匹配到对应规则。"""
|
||||
|
||||
def __init__(self, config: Config, cooldown: CooldownManager,
|
||||
runner: BetterGIRunner, logger: logging.Logger):
|
||||
self.config = config
|
||||
self.cooldown = cooldown
|
||||
self.runner = runner
|
||||
self.logger = logger
|
||||
|
||||
async def handle(self, text: str, uid: int, uname: str):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
admins = self.config.admin_uids
|
||||
is_admin = uid in admins
|
||||
|
||||
for rule in self.config.rules:
|
||||
if not self._match(text, rule):
|
||||
continue
|
||||
|
||||
keyword = rule["keyword"]
|
||||
groups = rule["groups"]
|
||||
cooldown_sec = rule.get("cooldown", self.config.default_cooldown)
|
||||
admin_only = rule.get("admin_only", False)
|
||||
reply = rule.get("reply", "")
|
||||
|
||||
# 权限检查
|
||||
if admin_only and not is_admin:
|
||||
self.logger.info(
|
||||
f"[权限不足] {uname}({uid}) 弹幕'{text}' 命中'{keyword}' 但需管理员"
|
||||
)
|
||||
return # 注意: 这里return了,继续检查下一条规则(如果有)
|
||||
|
||||
# 冷却检查
|
||||
if not self.cooldown.can_fire(keyword, cooldown_sec):
|
||||
remain = int(cooldown_sec - (time.time() - self.cooldown._last_fire.get(keyword, 0)))
|
||||
self.logger.info(
|
||||
f"[冷却中] '{keyword}' 还需 {remain}s, 来自 {uname}({uid})"
|
||||
)
|
||||
return # 冷却不触发,但日志已打印
|
||||
|
||||
# 触发
|
||||
self.cooldown.mark_fired(keyword)
|
||||
self.logger.info(
|
||||
f"[触发] {uname}({uid}) 弹幕'{text}' -> 配置组 {groups}"
|
||||
)
|
||||
if reply:
|
||||
self.logger.info(f"[回复提示] {reply}")
|
||||
await self.runner.run_groups(groups)
|
||||
return # 一条弹幕只触发第一个命中的规则
|
||||
|
||||
@staticmethod
|
||||
def _match(text: str, rule: dict) -> bool:
|
||||
keyword = rule["keyword"]
|
||||
mtype = rule.get("match_type", "contains")
|
||||
if mtype == "exact":
|
||||
return text == keyword
|
||||
elif mtype == "contains":
|
||||
return keyword in text
|
||||
elif mtype == "startswith":
|
||||
return text.startswith(keyword)
|
||||
elif mtype == "regex":
|
||||
import re
|
||||
return re.search(keyword, text) is not None
|
||||
return False
|
||||
|
||||
|
||||
# ============== B站弹幕协议 ==============
|
||||
def make_packet(op: int, body: bytes = b"") -> bytes:
|
||||
"""组装协议包。header 16字节 + body。"""
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
total = HEADER_LEN + len(body)
|
||||
header = struct.pack(">IHHII", total, HEADER_LEN, 1, op, 1)
|
||||
return header + body
|
||||
|
||||
|
||||
def parse_packets(data: bytes):
|
||||
"""一个 WebSocket 帧可能含多个协议包,循环切分。"""
|
||||
offset = 0
|
||||
packets = []
|
||||
while offset < len(data):
|
||||
if offset + HEADER_LEN > len(data):
|
||||
break
|
||||
total, header_len, proto_ver, op, seq = struct.unpack(
|
||||
">IHHII", data[offset:offset + HEADER_LEN]
|
||||
)
|
||||
body = data[offset + header_len:offset + total]
|
||||
packets.append((proto_ver, op, body))
|
||||
offset += total
|
||||
return packets
|
||||
|
||||
|
||||
def decode_body(proto_ver: int, body: bytes) -> bytes:
|
||||
"""按协议版本解压 body。"""
|
||||
if proto_ver == PROTO_JSON:
|
||||
return body
|
||||
if proto_ver == PROTO_ZLIB:
|
||||
return zlib.decompress(body)
|
||||
if proto_ver == PROTO_BROTLI:
|
||||
return brotli.decompress(body)
|
||||
return body
|
||||
|
||||
|
||||
def _clean_uid(value) -> int:
|
||||
"""只接受纯十进制正整数 UID,匿名或含星号的值返回 0。"""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else 0
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return int(value) if value.isdecimal() and int(value) > 0 else 0
|
||||
return 0
|
||||
|
||||
|
||||
def extract_danmu(body: bytes):
|
||||
"""从消息体中提取弹幕(DANMU_MSG)。返回 [(text, uid, uname), ...]。"""
|
||||
results = []
|
||||
try:
|
||||
decoded = decode_body(0, body) # body 已是解压后的,proto_ver 此处忽略
|
||||
except Exception:
|
||||
decoded = body
|
||||
try:
|
||||
msg = json.loads(decoded.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return results
|
||||
cmd = msg.get("cmd", "")
|
||||
if cmd.startswith("DANMU_MSG"):
|
||||
info = msg.get("info", [])
|
||||
if not isinstance(info, list) or len(info) <= 2:
|
||||
return results
|
||||
text = str(info[1] if len(info) > 1 else "")
|
||||
member = info[2]
|
||||
if isinstance(member, (list, tuple)):
|
||||
uid = _clean_uid(member[0] if len(member) > 0 else 0)
|
||||
uname = str(member[1] if len(member) > 1 and member[1] is not None else "")
|
||||
elif isinstance(member, dict):
|
||||
uid = _clean_uid(member.get("uid_str") or member.get("uid") or member.get("mid"))
|
||||
uname = str(member.get("uname") or member.get("name") or "")
|
||||
else:
|
||||
uid, uname = 0, ""
|
||||
results.append((text, uid, uname))
|
||||
return results
|
||||
|
||||
|
||||
# ============== 弹幕客户端 ==============
|
||||
class BliveClient:
|
||||
"""B站直播弹幕 WebSocket 客户端,带自动重连。"""
|
||||
|
||||
def __init__(self, room_id: int, sessdata: str,
|
||||
matcher: DanmuMatcher, logger: logging.Logger):
|
||||
self.room_id = room_id
|
||||
self.sessdata = sessdata
|
||||
self.matcher = matcher
|
||||
self.logger = logger
|
||||
self._stop = False
|
||||
self._buvid3 = ""
|
||||
|
||||
async def run(self):
|
||||
"""主循环:断线自动重连,间隔递增。"""
|
||||
# 启动时获取buvid3 (2024+协议认证必需)
|
||||
self._buvid3 = get_buvid3(self.sessdata)
|
||||
if self._buvid3:
|
||||
self.logger.info(f"获取buvid3成功: {self._buvid3[:20]}...")
|
||||
else:
|
||||
self.logger.warning("获取buvid3失败,弹幕可能收不到! 将尝试匿名连接")
|
||||
retry = 0
|
||||
while not self._stop:
|
||||
try:
|
||||
# 每次连接前重新获取服务器地址(避免IP变化)
|
||||
server_info = get_danmu_server(self.room_id)
|
||||
ws_url = server_info["ws_url"]
|
||||
token = server_info["token"]
|
||||
self.logger.info(f"弹幕服务器: {server_info['host']}:{server_info['port']}")
|
||||
await self._connect_once(ws_url, token)
|
||||
retry = 0 # 连上后重置
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.warning(f"连接断开: {e}")
|
||||
if self._stop:
|
||||
break
|
||||
retry += 1
|
||||
wait = min(2 ** retry, 60) # 指数退避,最多60秒
|
||||
self.logger.info(f"{wait}秒后重连(第{retry}次)...")
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
async def _connect_once(self, ws_url: str, token: str):
|
||||
self.logger.info(f"连接直播间 room_id={self.room_id} ...")
|
||||
# 禁用 websockets 自带 ping/pong (B站有自己的心跳协议)
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
max_size=None,
|
||||
ping_interval=None,
|
||||
ping_timeout=None,
|
||||
close_timeout=5,
|
||||
) as ws:
|
||||
# 1. 发送认证包(使用API返回的token + buvid3)
|
||||
auth = {
|
||||
"uid": 0,
|
||||
"roomid": self.room_id,
|
||||
"protover": PROTO_BROTLI,
|
||||
"platform": "web",
|
||||
"type": 2,
|
||||
"key": token or (self.sessdata or ""),
|
||||
}
|
||||
if self._buvid3:
|
||||
auth["buvid"] = self._buvid3
|
||||
await ws.send(make_packet(OP_AUTH, json.dumps(auth)))
|
||||
self.logger.info("已发送认证包,等待响应...")
|
||||
# 立即发送一个心跳,激活弹幕推送
|
||||
await ws.send(make_packet(OP_HEARTBEAT))
|
||||
|
||||
# 2. 心跳任务
|
||||
async def heartbeat():
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
try:
|
||||
await ws.send(make_packet(OP_HEARTBEAT))
|
||||
except Exception:
|
||||
break
|
||||
|
||||
hb_task = asyncio.create_task(heartbeat())
|
||||
|
||||
# 3. 接收循环
|
||||
msg_counter = 0
|
||||
async for raw in ws:
|
||||
if isinstance(raw, str):
|
||||
continue
|
||||
for proto_ver, op, body in parse_packets(raw):
|
||||
if op == OP_AUTH_REPLY:
|
||||
# 检查认证是否真的成功
|
||||
try:
|
||||
auth_resp = json.loads(body.decode("utf-8", errors="replace"))
|
||||
code = auth_resp.get("code", -1)
|
||||
if code == 0:
|
||||
self.logger.info(f"认证成功,开始监听弹幕 (响应: {auth_resp})")
|
||||
else:
|
||||
self.logger.error(f"认证失败! 响应: {auth_resp}")
|
||||
except Exception:
|
||||
self.logger.info(f"认证响应(原始): {body}")
|
||||
elif op == OP_HEARTBEAT_REPLY:
|
||||
# body 是 4 字节人气值(大端序int32)
|
||||
if len(body) >= 4:
|
||||
popularity = struct.unpack(">I", body[:4])[0]
|
||||
self.logger.debug(f"人气值: {popularity}")
|
||||
elif op == OP_MESSAGE:
|
||||
# body 可能被压缩,按 proto_ver 解压后再切包
|
||||
try:
|
||||
decoded = decode_body(proto_ver, body)
|
||||
except Exception as e:
|
||||
self.logger.error(f"解压失败 proto={proto_ver} len={len(body)}: {e}")
|
||||
continue
|
||||
# 解压后可能内含多个子包
|
||||
for sub_proto, sub_op, sub_body in parse_packets(decoded):
|
||||
if sub_op == OP_MESSAGE:
|
||||
msg_counter += 1
|
||||
# 调试:打印每条消息的cmd类型
|
||||
try:
|
||||
msg_json = json.loads(sub_body.decode("utf-8", errors="replace"))
|
||||
cmd = msg_json.get("cmd", "?")
|
||||
self.logger.debug(f"#{msg_counter} cmd={cmd}")
|
||||
except Exception:
|
||||
self.logger.debug(f"#{msg_counter} 解析JSON失败")
|
||||
continue
|
||||
for text, uid, uname in extract_danmu(sub_body):
|
||||
self.logger.info(f"[弹幕] {uname}({uid}): {text}")
|
||||
await self.matcher.handle(text, uid, uname)
|
||||
|
||||
hb_task.cancel()
|
||||
|
||||
|
||||
# ============== 日志 ==============
|
||||
def setup_logger(config: Config) -> logging.Logger:
|
||||
logger = logging.getLogger("danmu_bettergi")
|
||||
logger.setLevel(getattr(logging, config.data["global"].get("log_level", "INFO")))
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S")
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
logger.addHandler(sh)
|
||||
log_file = config.data["global"].get("log_file")
|
||||
if log_file:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fh = logging.FileHandler(project_path(log_file, base=LOG_DIR), encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
return logger
|
||||
|
||||
|
||||
# ============== 入口 ==============
|
||||
async def main():
|
||||
ensure_runtime_dirs()
|
||||
config_path = CONFIG_DIR / "config.json"
|
||||
if not config_path.exists():
|
||||
legacy_config_path = PROJECT_ROOT / "config.json"
|
||||
if legacy_config_path.exists():
|
||||
config_path = legacy_config_path
|
||||
if not config_path.exists():
|
||||
print("找不到 config.json,请先配置!")
|
||||
sys.exit(1)
|
||||
|
||||
config = Config(str(config_path))
|
||||
logger = setup_logger(config)
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("BetterGI 弹幕联动启动")
|
||||
logger.info(f"直播间: {config.room_id}")
|
||||
logger.info(f"BetterGI: {config.bettergi_exe}")
|
||||
logger.info(f"规则数: {len(config.rules)}")
|
||||
logger.info(f"管理员UID: {config.admin_uids or '无'}")
|
||||
logger.info(f"默认冷却: {config.default_cooldown}s")
|
||||
logger.info(f"重启模式: {config.restart_mode}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
cooldown = CooldownManager()
|
||||
runner = BetterGIRunner(
|
||||
config.bettergi_exe, config.bettergi_work_dir, logger,
|
||||
restart_mode=config.restart_mode,
|
||||
)
|
||||
matcher = DanmuMatcher(config, cooldown, runner, logger)
|
||||
client = BliveClient(config.room_id, config.sessdata, matcher, logger)
|
||||
|
||||
try:
|
||||
await client.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到退出信号,正在停止...")
|
||||
client.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
+10566
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import multiprocessing
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
MAX_NEW_TOKENS = 384
|
||||
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.0
|
||||
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
||||
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
||||
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
||||
DEFAULT_CPU_THREADS = 4
|
||||
DEFAULT_CPU_AFFINITY_COUNT = 8
|
||||
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
||||
|
||||
|
||||
class FasterQwenWorkerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FasterQwenWorkerTimeout(FasterQwenWorkerError):
|
||||
pass
|
||||
|
||||
|
||||
def _generation_kwargs(settings: dict[str, Any], text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"text": text,
|
||||
"language": str(settings.get("language") or "Chinese"),
|
||||
"non_streaming_mode": bool(settings.get("non_streaming_mode", True)),
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
|
||||
|
||||
def _bounded_int(value: Any, default: int, *, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(minimum, min(maximum, parsed))
|
||||
|
||||
|
||||
def _configure_worker_environment(settings: dict[str, Any]) -> int:
|
||||
cpu_threads = _bounded_int(
|
||||
settings.get("cpu_threads"),
|
||||
DEFAULT_CPU_THREADS,
|
||||
minimum=1,
|
||||
maximum=8,
|
||||
)
|
||||
thread_value = str(cpu_threads)
|
||||
for name in (
|
||||
"OMP_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
"VECLIB_MAXIMUM_THREADS",
|
||||
"BLIS_NUM_THREADS",
|
||||
):
|
||||
os.environ[name] = thread_value
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
return cpu_threads
|
||||
|
||||
|
||||
def _configure_torch_threads(torch_module: Any, cpu_threads: int) -> None:
|
||||
torch_module.set_num_threads(cpu_threads)
|
||||
try:
|
||||
torch_module.set_num_interop_threads(1)
|
||||
except RuntimeError:
|
||||
# PyTorch only allows setting interop threads before parallel work starts.
|
||||
pass
|
||||
|
||||
|
||||
def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
cpu_count = max(1, int(os.cpu_count() or 1))
|
||||
affinity_count = _bounded_int(
|
||||
settings.get("cpu_affinity_count"),
|
||||
DEFAULT_CPU_AFFINITY_COUNT,
|
||||
minimum=0,
|
||||
maximum=min(cpu_count, 63),
|
||||
)
|
||||
priority = str(settings.get("process_priority") or DEFAULT_PROCESS_PRIORITY).strip().lower()
|
||||
applied_affinity = 0
|
||||
applied_priority = "default"
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel32.SetPriorityClass.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel32.SetPriorityClass.restype = wintypes.BOOL
|
||||
kernel32.SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t]
|
||||
kernel32.SetProcessAffinityMask.restype = wintypes.BOOL
|
||||
process_handle = kernel32.GetCurrentProcess()
|
||||
priority_classes = {
|
||||
"idle": 0x00000040,
|
||||
"below_normal": 0x00004000,
|
||||
"normal": 0x00000020,
|
||||
}
|
||||
priority_class = priority_classes.get(priority, priority_classes[DEFAULT_PROCESS_PRIORITY])
|
||||
if kernel32.SetPriorityClass(process_handle, priority_class):
|
||||
applied_priority = priority if priority in priority_classes else DEFAULT_PROCESS_PRIORITY
|
||||
if affinity_count > 0:
|
||||
affinity_mask = (1 << affinity_count) - 1
|
||||
if kernel32.SetProcessAffinityMask(process_handle, affinity_mask):
|
||||
applied_affinity = affinity_count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"cpu_affinity_count": applied_affinity,
|
||||
"process_priority": applied_priority,
|
||||
}
|
||||
|
||||
|
||||
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
device = str(settings.get("device") or "cuda")
|
||||
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
|
||||
cpu_threads = _configure_worker_environment(settings)
|
||||
import torch
|
||||
_configure_torch_threads(torch, cpu_threads)
|
||||
from faster_qwen3_tts import FasterQwen3TTS
|
||||
|
||||
if device == "cpu":
|
||||
torch.cuda.is_available = lambda: False
|
||||
|
||||
load_kwargs: dict[str, Any] = {}
|
||||
if device == "cpu":
|
||||
load_kwargs["device"] = "cpu"
|
||||
model = FasterQwen3TTS.from_pretrained(
|
||||
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
|
||||
**load_kwargs,
|
||||
)
|
||||
|
||||
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
|
||||
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
|
||||
# _synthesize_wav 里直接传 ref_audio/ref_text/xvec_only 参数。
|
||||
voice_clone_prompt = None
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"torch": torch,
|
||||
"voice_clone_prompt": voice_clone_prompt,
|
||||
"settings": settings,
|
||||
"cpu_threads": cpu_threads,
|
||||
}
|
||||
|
||||
|
||||
def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
|
||||
import soundfile as sf
|
||||
|
||||
model = runtime["model"]
|
||||
torch = runtime["torch"]
|
||||
settings = runtime["settings"]
|
||||
safe_text = str(text or "").strip()[:80] or "欢迎来到直播间。"
|
||||
kwargs = _generation_kwargs(settings, safe_text)
|
||||
voice_clone_prompt = runtime.get("voice_clone_prompt")
|
||||
if voice_clone_prompt is not None:
|
||||
kwargs["voice_clone_prompt"] = voice_clone_prompt
|
||||
else:
|
||||
ref_audio = str(settings.get("ref_audio") or "")
|
||||
if not ref_audio:
|
||||
raise RuntimeError("Faster-Qwen3-TTS requires ref_audio")
|
||||
kwargs.update({
|
||||
"ref_audio": ref_audio,
|
||||
"ref_text": str(settings.get("ref_text") or "") or None,
|
||||
"xvec_only": bool(settings.get("xvec_only", True)),
|
||||
"append_silence": bool(settings.get("append_silence", True)),
|
||||
})
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
torch.backends.cudnn.enabled = False
|
||||
with torch.inference_mode():
|
||||
wavs, sample_rate = model.generate_voice_clone(**kwargs)
|
||||
|
||||
output = io.BytesIO()
|
||||
audio = wavs[0]
|
||||
if isinstance(audio, torch.Tensor):
|
||||
audio = audio.cpu().numpy()
|
||||
sf.write(output, audio, sample_rate, format="WAV")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
||||
try:
|
||||
cpu_threads = _configure_worker_environment(settings)
|
||||
process_limits = _apply_worker_process_limits(settings)
|
||||
load_started = time.monotonic()
|
||||
runtime = _load_runtime(settings)
|
||||
load_ms = int((time.monotonic() - load_started) * 1000)
|
||||
|
||||
warmup_started = time.monotonic()
|
||||
_synthesize_wav(runtime, "系统启动")
|
||||
warmup_ms = int((time.monotonic() - warmup_started) * 1000)
|
||||
connection.send({
|
||||
"type": "ready",
|
||||
"pid": os.getpid(),
|
||||
"load_ms": load_ms,
|
||||
"warmup_ms": warmup_ms,
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
"cpu_threads": cpu_threads,
|
||||
**process_limits,
|
||||
})
|
||||
except BaseException as exc:
|
||||
try:
|
||||
connection.send({
|
||||
"type": "startup_error",
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc),
|
||||
"traceback": traceback.format_exc(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
connection.close()
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = connection.recv()
|
||||
except (EOFError, OSError):
|
||||
break
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
command = str(message.get("command") or "")
|
||||
if command == "stop":
|
||||
break
|
||||
if command != "synthesize":
|
||||
continue
|
||||
|
||||
request_id = str(message.get("request_id") or "")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
audio = _synthesize_wav(runtime, str(message.get("text") or ""))
|
||||
connection.send({
|
||||
"type": "result",
|
||||
"request_id": request_id,
|
||||
"audio": audio,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"bytes": len(audio),
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
})
|
||||
except BaseException as exc:
|
||||
try:
|
||||
connection.send({
|
||||
"type": "error",
|
||||
"request_id": request_id,
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc),
|
||||
"traceback": traceback.format_exc(),
|
||||
})
|
||||
except Exception:
|
||||
break
|
||||
connection.close()
|
||||
|
||||
|
||||
class FasterQwenWorkerClient:
|
||||
def __init__(
|
||||
self,
|
||||
settings: dict[str, Any],
|
||||
logger=None,
|
||||
*,
|
||||
synthesis_timeout_seconds: float = DEFAULT_SYNTHESIS_TIMEOUT_SECONDS,
|
||||
startup_timeout_seconds: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
context=None,
|
||||
process_target: Callable[..., None] | None = None,
|
||||
):
|
||||
self.settings = dict(settings)
|
||||
self.logger = logger
|
||||
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
|
||||
self.startup_timeout_seconds = max(10.0, float(startup_timeout_seconds))
|
||||
self._context = context or multiprocessing.get_context("spawn")
|
||||
self._process_target = process_target or faster_qwen_worker_main
|
||||
self._lock = threading.RLock()
|
||||
self._process = None
|
||||
self._connection = None
|
||||
self._worker_pid = 0
|
||||
self._next_start_after = 0.0
|
||||
|
||||
@property
|
||||
def worker_pid(self) -> int:
|
||||
return int(self._worker_pid or 0)
|
||||
|
||||
def _log(self, level: str, message: str, *args) -> None:
|
||||
if self.logger:
|
||||
getattr(self.logger, level)(message, *args)
|
||||
|
||||
def _is_alive_locked(self) -> bool:
|
||||
return bool(self._process is not None and self._process.is_alive())
|
||||
|
||||
def ensure_ready(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._is_alive_locked() and self._connection is not None:
|
||||
return {"pid": self.worker_pid, "reused": True}
|
||||
return self._start_worker_locked()
|
||||
|
||||
def _start_worker_locked(self) -> dict[str, Any]:
|
||||
now = time.monotonic()
|
||||
if now < self._next_start_after:
|
||||
wait = int(self._next_start_after - now)
|
||||
raise FasterQwenWorkerError(
|
||||
f"TTS worker 启动退避中,距上次启动失败不足 {int(STARTUP_FAILURE_BACKOFF_SECONDS)} 秒,"
|
||||
f"约 {wait} 秒后可重试"
|
||||
)
|
||||
self._terminate_worker_locked("replace_stale_worker", graceful=False)
|
||||
parent_connection, child_connection = self._context.Pipe(duplex=True)
|
||||
process = self._context.Process(
|
||||
target=self._process_target,
|
||||
args=(child_connection, self.settings),
|
||||
name="FasterQwen3TTSWorker",
|
||||
daemon=True,
|
||||
)
|
||||
process.start()
|
||||
try:
|
||||
child_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = process
|
||||
self._connection = parent_connection
|
||||
self._worker_pid = int(getattr(process, "pid", 0) or 0)
|
||||
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
|
||||
|
||||
if not parent_connection.poll(self.startup_timeout_seconds):
|
||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||
self._terminate_worker_locked("startup_timeout", graceful=False)
|
||||
raise FasterQwenWorkerError(
|
||||
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s"
|
||||
)
|
||||
try:
|
||||
message = parent_connection.recv()
|
||||
except (EOFError, OSError) as exc:
|
||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||
self._terminate_worker_locked("startup_connection_closed", graceful=False)
|
||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc
|
||||
if not isinstance(message, dict) or message.get("type") != "ready":
|
||||
if isinstance(message, dict):
|
||||
error = str(message.get("error") or message.get("error_type") or "unknown startup error")
|
||||
else:
|
||||
error = "invalid startup response"
|
||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||
self._terminate_worker_locked("startup_error", graceful=False)
|
||||
raise FasterQwenWorkerError(error)
|
||||
self._next_start_after = 0.0
|
||||
self._log(
|
||||
"info",
|
||||
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s",
|
||||
self.worker_pid,
|
||||
message.get("load_ms"),
|
||||
message.get("warmup_ms"),
|
||||
message.get("max_new_tokens"),
|
||||
)
|
||||
self._log(
|
||||
"info",
|
||||
"[FasterQwenTTS] worker 资源限制: cpu_threads=%s affinity=%s priority=%s",
|
||||
message.get("cpu_threads"),
|
||||
message.get("cpu_affinity_count"),
|
||||
message.get("process_priority"),
|
||||
)
|
||||
return message
|
||||
|
||||
def synthesize(self, text: str) -> tuple[bytes, dict[str, Any]]:
|
||||
with self._lock:
|
||||
self.ensure_ready()
|
||||
request_id = uuid.uuid4().hex
|
||||
connection = self._connection
|
||||
try:
|
||||
connection.send({
|
||||
"command": "synthesize",
|
||||
"request_id": request_id,
|
||||
"text": str(text or ""),
|
||||
})
|
||||
except (BrokenPipeError, EOFError, OSError) as exc:
|
||||
self._restart_after_failure_locked("send_failed")
|
||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker connection failed") from exc
|
||||
|
||||
if not connection.poll(self.synthesis_timeout_seconds):
|
||||
self._log(
|
||||
"error",
|
||||
"[FasterQwenTTS] 单次合成超过 %.0f 秒,强制终止 worker pid=%s",
|
||||
self.synthesis_timeout_seconds,
|
||||
self.worker_pid,
|
||||
)
|
||||
restart_error = self._restart_after_failure_locked("synthesis_timeout")
|
||||
suffix = f"; restart failed: {restart_error}" if restart_error else ""
|
||||
raise FasterQwenWorkerTimeout(
|
||||
f"Faster-Qwen3-TTS synthesis exceeded {self.synthesis_timeout_seconds:.0f}s{suffix}"
|
||||
)
|
||||
try:
|
||||
message = connection.recv()
|
||||
except (EOFError, OSError) as exc:
|
||||
self._restart_after_failure_locked("worker_exited")
|
||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during synthesis") from exc
|
||||
|
||||
if not isinstance(message, dict) or message.get("request_id") != request_id:
|
||||
self._restart_after_failure_locked("invalid_response")
|
||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker returned an invalid response")
|
||||
if message.get("type") == "error":
|
||||
error = str(message.get("error") or message.get("error_type") or "synthesis failed")
|
||||
lowered = error.lower()
|
||||
if "cuda" in lowered or "out of memory" in lowered or "device-side" in lowered:
|
||||
self._restart_after_failure_locked("cuda_error")
|
||||
raise FasterQwenWorkerError(error)
|
||||
if message.get("type") != "result" or not isinstance(message.get("audio"), bytes):
|
||||
self._restart_after_failure_locked("invalid_result")
|
||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker returned no audio")
|
||||
return message["audio"], message
|
||||
|
||||
def _restart_after_failure_locked(self, reason: str) -> str:
|
||||
self._terminate_worker_locked(reason, graceful=False)
|
||||
try:
|
||||
self._start_worker_locked()
|
||||
return ""
|
||||
except Exception as exc:
|
||||
self._log("error", "[FasterQwenTTS] worker 自动重启失败: %s", exc)
|
||||
return str(exc)
|
||||
|
||||
def _terminate_worker_locked(self, reason: str, *, graceful: bool) -> None:
|
||||
process = self._process
|
||||
connection = self._connection
|
||||
self._process = None
|
||||
self._connection = None
|
||||
self._worker_pid = 0
|
||||
if process is None:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if graceful and process.is_alive() and connection is not None:
|
||||
try:
|
||||
connection.send({"command": "stop"})
|
||||
process.join(timeout=3.0)
|
||||
except Exception:
|
||||
pass
|
||||
if process.is_alive():
|
||||
self._log("warning", "[FasterQwenTTS] 终止 worker, reason=%s pid=%s", reason, process.pid)
|
||||
process.terminate()
|
||||
process.join(timeout=10.0)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join(timeout=5.0)
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._terminate_worker_locked("shutdown", graceful=True)
|
||||
@@ -0,0 +1,569 @@
|
||||
"""安全、幂等地将旧版 JSON/日志快照补录到统计数据库。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .stats_store import DEFAULT_DATABASE_PATH, StatsStore, business_date
|
||||
except ImportError: # 允许直接执行文件
|
||||
from stats_store import DEFAULT_DATABASE_PATH, StatsStore, business_date
|
||||
|
||||
|
||||
BEIJING_TZ = timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
_SOURCE_FILES = (
|
||||
"users.json",
|
||||
"song_requests.json",
|
||||
"admin_audit.log",
|
||||
"queue_state.json",
|
||||
"music_state.json",
|
||||
"tts_state.json",
|
||||
)
|
||||
_USER_FIELDS = {
|
||||
"uname",
|
||||
"points",
|
||||
"last_signin_date",
|
||||
"created_at",
|
||||
"blocked_all",
|
||||
"blocked_queue",
|
||||
"blocked_song_request",
|
||||
"note",
|
||||
}
|
||||
_SONG_FIELDS = {
|
||||
"id",
|
||||
"name",
|
||||
"artist",
|
||||
"duration_ms",
|
||||
"duration_sec",
|
||||
"keyword",
|
||||
"uid",
|
||||
"uname",
|
||||
"requested_at",
|
||||
"source",
|
||||
"remove_after_play",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"status",
|
||||
}
|
||||
_AUDIT_FIELDS = {"at", "action", "target", "client_ip", "session_id", "detail"}
|
||||
TABLE_COLUMNS = {
|
||||
"users": {
|
||||
"platform", "platform_user_id", "display_name", "avatar_url", "user_level",
|
||||
"is_admin", "first_seen_at_utc", "last_seen_at_utc", "snapshot_json",
|
||||
},
|
||||
"song_requests": {
|
||||
"request_id", "platform", "platform_user_id", "requested_at_utc", "business_date",
|
||||
"song_id", "song_name", "artist", "source", "status", "points_cost",
|
||||
"queue_position", "payload_json",
|
||||
},
|
||||
"playback_sessions": {
|
||||
"playback_id", "request_id", "song_id", "song_name", "started_at_utc",
|
||||
"ended_at_utc", "business_date", "status", "duration_ms", "played_ms",
|
||||
"stop_reason", "payload_json",
|
||||
},
|
||||
"admin_audit_events": {
|
||||
"event_id", "occurred_at_utc", "business_date", "actor", "action",
|
||||
"target_type", "target_id", "success", "remote_address_hash", "payload_json",
|
||||
},
|
||||
"point_transactions": {
|
||||
"transaction_id", "platform", "platform_user_id", "occurred_at_utc",
|
||||
"business_date", "amount", "balance_after", "reason", "reference_type",
|
||||
"reference_id", "payload_json",
|
||||
},
|
||||
"events": {
|
||||
"event_id", "event_type", "category", "occurred_at_utc", "business_date",
|
||||
"payload_json",
|
||||
},
|
||||
}
|
||||
_POINT_DETAIL_RE = re.compile(r"^delta=(-?\d+)\s+now=(-?\d+)$")
|
||||
_COUNT_DETAIL_RE = re.compile(r"^count=(\d+)$")
|
||||
_STATUS_MAP = {
|
||||
"played": "completed",
|
||||
"complete": "completed",
|
||||
"finished": "completed",
|
||||
"success": "completed",
|
||||
"skipped": "skipped",
|
||||
"skip": "skipped",
|
||||
"interrupted": "interrupted",
|
||||
"cancelled": "cancelled",
|
||||
"canceled": "cancelled",
|
||||
"play_error": "failed",
|
||||
"error": "failed",
|
||||
"failed": "failed",
|
||||
"queued": "queued",
|
||||
"pending": "queued",
|
||||
"playing": "playing",
|
||||
"active": "playing",
|
||||
}
|
||||
|
||||
|
||||
def _hash(*parts: Any) -> str:
|
||||
encoded = json.dumps(parts, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8", "replace")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _text(value: Any, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
result = str(value).strip()
|
||||
return result[:limit] if result else None
|
||||
|
||||
|
||||
def _int(value: Any, *, minimum: int | None = None) -> int | None:
|
||||
try:
|
||||
result = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if minimum is not None and result < minimum:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
return value is True or value == 1
|
||||
|
||||
|
||||
def _utc_iso(
|
||||
value: Any,
|
||||
*,
|
||||
naive_is_beijing: bool = True,
|
||||
fallback: datetime | None = None,
|
||||
timespec: str = "milliseconds",
|
||||
) -> str:
|
||||
parsed: datetime
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
parsed = datetime.fromtimestamp(float(value), UTC)
|
||||
elif isinstance(value, str) and value.strip():
|
||||
raw = value.strip()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
parsed = fallback or datetime.now(UTC)
|
||||
else:
|
||||
parsed = fallback or datetime.now(UTC)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=BEIJING_TZ if naive_is_beijing else UTC)
|
||||
return parsed.astimezone(UTC).isoformat(timespec=timespec).replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _file_time(path: Path) -> str:
|
||||
return _utc_iso(datetime.fromtimestamp(path.stat().st_mtime, UTC), naive_is_beijing=False)
|
||||
|
||||
|
||||
def _read_bytes(path: Path) -> bytes:
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _read_json(raw: bytes) -> Any:
|
||||
return json.loads(raw.decode("utf-8-sig"))
|
||||
|
||||
|
||||
def _fingerprint(raw: bytes) -> str:
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _normalize_status(value: Any, default: str) -> str:
|
||||
key = str(value or "").strip().casefold()
|
||||
return _STATUS_MAP.get(key, default)
|
||||
|
||||
|
||||
def _json_payload(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: item for key, item in value.items() if item is not None}
|
||||
|
||||
|
||||
def _user_records(path: Path, raw: bytes) -> list[tuple[str, dict[str, Any]]]:
|
||||
data = _read_json(raw)
|
||||
if not isinstance(data, Mapping):
|
||||
return []
|
||||
snapshot_at = _file_time(path)
|
||||
records: list[tuple[str, dict[str, Any]]] = []
|
||||
for uid, original in data.items():
|
||||
if not isinstance(original, Mapping):
|
||||
continue
|
||||
item = {key: original[key] for key in _USER_FIELDS if key in original}
|
||||
platform_user_id = _text(uid, 64)
|
||||
if not platform_user_id:
|
||||
continue
|
||||
created_at = _utc_iso(item.get("created_at"), fallback=datetime.fromtimestamp(path.stat().st_mtime, UTC))
|
||||
snapshot = _json_payload({
|
||||
"points": _int(item.get("points")),
|
||||
"last_signin_date": _text(item.get("last_signin_date"), 10),
|
||||
"blocked_all": _bool(item.get("blocked_all")),
|
||||
"blocked_queue": _bool(item.get("blocked_queue")),
|
||||
"blocked_song_request": _bool(item.get("blocked_song_request")),
|
||||
"note_length": len(str(item.get("note") or "")),
|
||||
"legacy_snapshot": True,
|
||||
})
|
||||
records.append(("users", {
|
||||
"platform": "bilibili",
|
||||
"platform_user_id": platform_user_id,
|
||||
"display_name": _text(item.get("uname"), 128),
|
||||
"avatar_url": None,
|
||||
"user_level": None,
|
||||
"is_admin": False,
|
||||
"first_seen_at_utc": created_at,
|
||||
"last_seen_at_utc": snapshot_at,
|
||||
"snapshot_json": snapshot,
|
||||
}))
|
||||
return records
|
||||
|
||||
|
||||
def _song_records(path: Path, raw: bytes) -> list[tuple[str, dict[str, Any]]]:
|
||||
data = _read_json(raw)
|
||||
if not isinstance(data, Mapping):
|
||||
return []
|
||||
snapshot_at = _file_time(path)
|
||||
fallback = datetime.fromtimestamp(path.stat().st_mtime, UTC)
|
||||
entries: list[dict[str, Any]] = []
|
||||
sections = (("queue", data.get("queue")), ("active", [data.get("active")]), ("history", data.get("history")))
|
||||
for section, values in sections:
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
section_entries: dict[tuple[str | None, str, str | None, str], list[dict[str, Any]]] = {}
|
||||
for queue_position, original in enumerate(values, 1):
|
||||
if not isinstance(original, Mapping):
|
||||
continue
|
||||
item = {key: original[key] for key in _SONG_FIELDS if key in original}
|
||||
requested = _utc_iso(item.get("requested_at"), fallback=fallback, timespec="microseconds")
|
||||
source = _text(item.get("source"), 64) or "legacy"
|
||||
business_key = (
|
||||
_text(item.get("uid"), 64),
|
||||
requested,
|
||||
_text(item.get("id"), 128),
|
||||
source,
|
||||
)
|
||||
entry = {
|
||||
"section": section,
|
||||
"item": item,
|
||||
"requested": requested,
|
||||
"started": _utc_iso(item["started_at"], timespec="microseconds") if item.get("started_at") is not None else None,
|
||||
"ended": _utc_iso(item["finished_at"], timespec="microseconds") if item.get("finished_at") is not None else None,
|
||||
"source": source,
|
||||
"business_key": business_key,
|
||||
"queue_position": queue_position if section == "queue" else None,
|
||||
}
|
||||
section_entries.setdefault(business_key, []).append(entry)
|
||||
for business_key, duplicates in section_entries.items():
|
||||
duplicates.sort(key=lambda entry: (
|
||||
entry["started"] or "",
|
||||
entry["ended"] or "",
|
||||
str(entry["item"].get("status") or ""),
|
||||
str(entry["item"].get("name") or ""),
|
||||
str(entry["item"].get("artist") or ""),
|
||||
str(entry["item"].get("duration_ms") or ""),
|
||||
))
|
||||
for duplicate_ordinal, entry in enumerate(duplicates):
|
||||
entry["request_id"] = _hash("legacy-song-request", *business_key, duplicate_ordinal)
|
||||
entries.append(entry)
|
||||
|
||||
section_priority = {"queue": 0, "active": 1, "history": 2}
|
||||
requests: dict[str, dict[str, Any]] = {}
|
||||
playbacks: dict[str, dict[str, Any]] = {}
|
||||
for entry in sorted(entries, key=lambda value: section_priority[value["section"]]):
|
||||
section = entry["section"]
|
||||
item = entry["item"]
|
||||
request_id = entry["request_id"]
|
||||
confidence = "snapshot" if section in {"queue", "active"} else "history"
|
||||
default_status = "queued" if section == "queue" else "playing" if section == "active" else "unknown"
|
||||
status = _normalize_status(item.get("status"), default_status)
|
||||
requests[request_id] = {
|
||||
"request_id": request_id,
|
||||
"platform": "bilibili",
|
||||
"platform_user_id": _text(item.get("uid"), 64),
|
||||
"requested_at_utc": entry["requested"],
|
||||
"business_date": business_date(entry["requested"]),
|
||||
"song_id": _text(item.get("id"), 128),
|
||||
"song_name": _text(item.get("name"), 256),
|
||||
"artist": _text(item.get("artist"), 256),
|
||||
"source": entry["source"],
|
||||
"status": status,
|
||||
"points_cost": 0,
|
||||
"queue_position": entry["queue_position"],
|
||||
"payload_json": _json_payload({
|
||||
"legacy_section": section,
|
||||
"confidence": confidence,
|
||||
"latest_80_only": section == "history",
|
||||
"keyword_length": len(str(item.get("keyword") or "")),
|
||||
"remove_after_play": _bool(item.get("remove_after_play")),
|
||||
"snapshot_at_utc": snapshot_at if confidence == "snapshot" else None,
|
||||
}),
|
||||
}
|
||||
started = entry["started"]
|
||||
if not started:
|
||||
continue
|
||||
ended = entry["ended"]
|
||||
played_ms = None
|
||||
if ended:
|
||||
start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(ended.replace("Z", "+00:00"))
|
||||
played_ms = max(0, round((end_dt - start_dt).total_seconds() * 1000))
|
||||
playback_id = _hash("legacy-playback", request_id, started)
|
||||
playbacks[playback_id] = {
|
||||
"playback_id": playback_id,
|
||||
"request_id": request_id,
|
||||
"song_id": _text(item.get("id"), 128),
|
||||
"song_name": _text(item.get("name"), 256),
|
||||
"started_at_utc": started,
|
||||
"ended_at_utc": ended,
|
||||
"business_date": business_date(started),
|
||||
"status": status,
|
||||
"duration_ms": _int(item.get("duration_ms"), minimum=0),
|
||||
"played_ms": played_ms,
|
||||
"stop_reason": "snapshot" if confidence == "snapshot" else status,
|
||||
"payload_json": {
|
||||
"legacy_section": section,
|
||||
"confidence": confidence,
|
||||
"latest_80_only": section == "history",
|
||||
},
|
||||
}
|
||||
return [
|
||||
*(("song_requests", record) for record in requests.values()),
|
||||
*(("playback_sessions", record) for record in playbacks.values()),
|
||||
]
|
||||
|
||||
|
||||
def _safe_detail(detail: Any) -> dict[str, Any]:
|
||||
text = str(detail or "")
|
||||
result: dict[str, Any] = {
|
||||
"detail_length": len(text),
|
||||
"detail_sha256": _hash("admin-detail", text),
|
||||
}
|
||||
count_match = _COUNT_DETAIL_RE.fullmatch(text)
|
||||
if count_match:
|
||||
result.update({"detail_kind": "count", "count": int(count_match.group(1))})
|
||||
return result
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
if isinstance(parsed, Mapping):
|
||||
allowed_flags = {key: _bool(parsed[key]) for key in ("blocked_all", "blocked_queue", "blocked_song_request") if key in parsed}
|
||||
if allowed_flags:
|
||||
result.update({"detail_kind": "flags", "flags": allowed_flags})
|
||||
return result
|
||||
result["detail_kind"] = "opaque"
|
||||
return result
|
||||
|
||||
|
||||
def _audit_records(raw: bytes) -> list[tuple[str, dict[str, Any]]]:
|
||||
records: list[tuple[str, dict[str, Any]]] = []
|
||||
duplicate_counts: Counter[str] = Counter()
|
||||
for raw_line in raw.decode("utf-8-sig", "replace").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
original = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(original, Mapping):
|
||||
continue
|
||||
line_hash = _hash("legacy-admin-audit-line", line)
|
||||
duplicate_ordinal = duplicate_counts[line_hash]
|
||||
duplicate_counts[line_hash] += 1
|
||||
item = {key: original[key] for key in _AUDIT_FIELDS if key in original}
|
||||
occurred = _utc_iso(item.get("at"), naive_is_beijing=True)
|
||||
action = _text(item.get("action"), 96) or "unknown"
|
||||
target = _text(item.get("target"), 256)
|
||||
event_id = _hash("legacy-admin-audit", line_hash, duplicate_ordinal)
|
||||
payload = _safe_detail(item.get("detail"))
|
||||
session = _text(item.get("session_id"), 512)
|
||||
if session:
|
||||
payload["session_hash"] = _hash("admin-session", session)
|
||||
records.append(("admin_audit_events", {
|
||||
"event_id": event_id,
|
||||
"occurred_at_utc": occurred,
|
||||
"business_date": business_date(occurred),
|
||||
"actor": "admin",
|
||||
"action": action,
|
||||
"target_type": "legacy_target",
|
||||
"target_id": target,
|
||||
"success": True,
|
||||
"remote_address_hash": _hash("admin-ip", item.get("client_ip")) if item.get("client_ip") else None,
|
||||
"payload_json": payload,
|
||||
}))
|
||||
point_match = _POINT_DETAIL_RE.fullmatch(str(item.get("detail") or "")) if action == "user_add_points" else None
|
||||
if point_match and target:
|
||||
records.append(("point_transactions", {
|
||||
"transaction_id": _hash("legacy-admin-points", event_id),
|
||||
"platform": "bilibili",
|
||||
"platform_user_id": target,
|
||||
"occurred_at_utc": occurred,
|
||||
"business_date": business_date(occurred),
|
||||
"amount": int(point_match.group(1)),
|
||||
"balance_after": int(point_match.group(2)),
|
||||
"reason": "admin_adjustment",
|
||||
"reference_type": "admin_audit",
|
||||
"reference_id": event_id,
|
||||
"payload_json": {"legacy_import": True},
|
||||
}))
|
||||
return records
|
||||
|
||||
|
||||
def _snapshot_event(path: Path, raw: bytes) -> list[tuple[str, dict[str, Any]]]:
|
||||
data = _read_json(raw)
|
||||
if not isinstance(data, Mapping):
|
||||
return []
|
||||
monitor_updated_at = (data.get("monitor") or {}).get("updated_at") if isinstance(data.get("monitor"), Mapping) else None
|
||||
occurred = _utc_iso(
|
||||
data.get("updated_at") or monitor_updated_at,
|
||||
fallback=datetime.fromtimestamp(path.stat().st_mtime, UTC),
|
||||
)
|
||||
if path.name == "queue_state.json":
|
||||
queue = data.get("queue") if isinstance(data.get("queue"), list) else []
|
||||
payload = {
|
||||
"queue_size": len(queue),
|
||||
"has_active_user": bool(data.get("current_admin_uid")),
|
||||
"has_group": bool(data.get("current_group")),
|
||||
"default_running": _bool(data.get("default_running")),
|
||||
"login_status": _text(data.get("login_status"), 64),
|
||||
"has_user_finished_once": _bool(data.get("has_user_finished_once")),
|
||||
"confidence": "snapshot",
|
||||
}
|
||||
event_type = "legacy.queue_snapshot"
|
||||
category = "queue"
|
||||
elif path.name == "music_state.json":
|
||||
current = data.get("current") if isinstance(data.get("current"), Mapping) else {}
|
||||
monitor = data.get("monitor") if isinstance(data.get("monitor"), Mapping) else {}
|
||||
payload = {
|
||||
"playing": _bool(data.get("playing")),
|
||||
"current_title": _text(current.get("title"), 256),
|
||||
"current_artist": _text(current.get("artist"), 256),
|
||||
"duration": _int(current.get("duration"), minimum=0),
|
||||
"progress": _int(current.get("progress"), minimum=0),
|
||||
"playlist_size": len(data.get("playlist")) if isinstance(data.get("playlist"), list) else 0,
|
||||
"request_size": len(data.get("requests")) if isinstance(data.get("requests"), list) else 0,
|
||||
"monitor_online": _bool(monitor.get("online")),
|
||||
"platform": _text(monitor.get("platform"), 32),
|
||||
"confidence": "snapshot",
|
||||
}
|
||||
event_type = "legacy.music_snapshot"
|
||||
category = "music"
|
||||
else:
|
||||
recent = data.get("recent_events") if isinstance(data.get("recent_events"), list) else []
|
||||
payload = {
|
||||
"enabled": _bool(data.get("enabled")),
|
||||
"provider": _text(data.get("provider"), 64),
|
||||
"model_loaded": _bool(data.get("model_loaded")),
|
||||
"last_duration_ms": _int(data.get("last_duration_ms"), minimum=0),
|
||||
"has_last_error": bool(data.get("last_error")),
|
||||
"total_synthesized": _int(data.get("total_synthesized"), minimum=0),
|
||||
"total_errors": _int(data.get("total_errors"), minimum=0),
|
||||
"recent_event_count": len(recent),
|
||||
"last_text_length": len(str(data.get("last_text") or "")),
|
||||
"confidence": "snapshot",
|
||||
}
|
||||
event_type = "legacy.tts_snapshot"
|
||||
category = "tts"
|
||||
payload = _json_payload(payload)
|
||||
return [("events", {
|
||||
"event_id": _hash("legacy-snapshot", path.name, _fingerprint(raw)),
|
||||
"event_type": event_type,
|
||||
"category": category,
|
||||
"occurred_at_utc": occurred,
|
||||
"business_date": business_date(occurred),
|
||||
"payload_json": payload,
|
||||
})]
|
||||
|
||||
|
||||
def _build_records(path: Path, raw: bytes) -> list[tuple[str, dict[str, Any]]]:
|
||||
if path.name == "users.json":
|
||||
return _user_records(path, raw)
|
||||
if path.name == "song_requests.json":
|
||||
return _song_records(path, raw)
|
||||
if path.name == "admin_audit.log":
|
||||
return _audit_records(raw)
|
||||
return _snapshot_event(path, raw)
|
||||
|
||||
|
||||
def validate_records(records: list[tuple[str, dict[str, Any]]]) -> None:
|
||||
"""拒绝未知表、未知字段及非映射记录,避免导入边界被意外扩大。"""
|
||||
for record_number, record in enumerate(records, 1):
|
||||
if not isinstance(record, tuple) or len(record) != 2:
|
||||
raise ValueError(f"第 {record_number} 条导入记录格式无效")
|
||||
table, fields = record
|
||||
allowed_columns = TABLE_COLUMNS.get(table)
|
||||
if allowed_columns is None:
|
||||
raise ValueError(f"第 {record_number} 条记录使用未知表: {table}")
|
||||
if not isinstance(fields, Mapping):
|
||||
raise ValueError(f"第 {record_number} 条记录字段不是映射")
|
||||
unknown_columns = set(fields) - allowed_columns
|
||||
if unknown_columns:
|
||||
raise ValueError(f"{table} 包含未知字段: {sorted(unknown_columns)}")
|
||||
if not fields:
|
||||
raise ValueError(f"{table} 导入记录不能为空")
|
||||
|
||||
|
||||
async def backfill_legacy_statistics(store: StatsStore, data_dir: str | Path, dry_run: bool = False) -> dict[str, Any]:
|
||||
"""扫描旧数据并通过 ``StatsStore.import_once`` 原子、幂等地补录。"""
|
||||
root = Path(data_dir)
|
||||
result: dict[str, Any] = {"dry_run": bool(dry_run), "sources": {}, "record_counts": {}}
|
||||
totals: Counter[str] = Counter()
|
||||
for filename in _SOURCE_FILES:
|
||||
path = root / filename
|
||||
if not path.is_file():
|
||||
result["sources"][filename] = {"status": "missing", "records": 0}
|
||||
continue
|
||||
raw = _read_bytes(path)
|
||||
records = _build_records(path, raw)
|
||||
validate_records(records)
|
||||
counts = Counter(table for table, _ in records)
|
||||
totals.update(counts)
|
||||
imported = True
|
||||
if not dry_run:
|
||||
imported = await store.import_once(
|
||||
f"legacy-history:{filename}",
|
||||
"legacy_history",
|
||||
records,
|
||||
fingerprint=_fingerprint(raw),
|
||||
checkpoint_key="sha256",
|
||||
checkpoint_value=_fingerprint(raw),
|
||||
metadata={"filename": filename, "record_count": len(records), "schema": 1},
|
||||
)
|
||||
result["sources"][filename] = {
|
||||
"status": "dry_run" if dry_run else "completed" if imported else "failed",
|
||||
"records": len(records),
|
||||
"tables": dict(sorted(counts.items())),
|
||||
}
|
||||
result["record_counts"] = dict(sorted(totals.items()))
|
||||
result["total_records"] = sum(totals.values())
|
||||
return result
|
||||
|
||||
|
||||
async def _main_async(args: argparse.Namespace) -> int:
|
||||
data_dir = Path(args.data_dir)
|
||||
if args.dry_run:
|
||||
result = await backfill_legacy_statistics(StatsStore(args.database), data_dir, dry_run=True)
|
||||
else:
|
||||
store = StatsStore(args.database)
|
||||
if not await store.start():
|
||||
print(json.dumps({"status": "failed", "reason": "database_start_failed"}, ensure_ascii=False))
|
||||
return 1
|
||||
try:
|
||||
result = await backfill_legacy_statistics(store, data_dir)
|
||||
await store.flush()
|
||||
finally:
|
||||
await store.close()
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if all(source["status"] != "failed" for source in result["sources"].values()) else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="安全、幂等地补录旧版直播统计")
|
||||
parser.add_argument("--data-dir", default=str(Path(__file__).resolve().parents[1] / "data"))
|
||||
parser.add_argument("--database", default=str(DEFAULT_DATABASE_PATH))
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
return asyncio.run(_main_async(parser.parse_args()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
"""Unified launcher for source and frozen builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import ctypes
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
from core.runtime_paths import APP_ROOT, DATA_DIR, ensure_runtime_dirs
|
||||
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
MAIN_INSTANCE_MUTEX = "Local\\BetterGI_LiveStreaming_Main_5191"
|
||||
|
||||
|
||||
class SingleInstanceLock:
|
||||
"""Windows named mutex used by the queue-producing main process only."""
|
||||
|
||||
def __init__(self, name: str = MAIN_INSTANCE_MUTEX):
|
||||
self.name = name
|
||||
self._handle = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
if os.name != "nt":
|
||||
return True
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
handle = kernel32.CreateMutexW(None, False, self.name)
|
||||
last_error = ctypes.get_last_error()
|
||||
if not handle:
|
||||
raise ctypes.WinError(last_error)
|
||||
self._handle = handle
|
||||
if last_error == ERROR_ALREADY_EXISTS:
|
||||
self.close()
|
||||
return False
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if self._handle is None or os.name != "nt":
|
||||
return
|
||||
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
|
||||
|
||||
class _JOBOBJECT_IO_COUNTERS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ReadOperationCount", ctypes.c_ulonglong),
|
||||
("WriteOperationCount", ctypes.c_ulonglong),
|
||||
("OtherOperationCount", ctypes.c_ulonglong),
|
||||
("ReadTransferCount", ctypes.c_ulonglong),
|
||||
("WriteTransferCount", ctypes.c_ulonglong),
|
||||
("OtherTransferCount", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
|
||||
class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||
("LimitFlags", ctypes.c_ulong),
|
||||
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||
("ActiveProcessLimit", ctypes.c_ulong),
|
||||
("Affinity", ctypes.c_size_t),
|
||||
("PriorityClass", ctypes.c_ulong),
|
||||
("SchedulingClass", ctypes.c_ulong),
|
||||
]
|
||||
|
||||
|
||||
class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
||||
("IoInfo", _JOBOBJECT_IO_COUNTERS),
|
||||
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||
("JobMemoryLimit", ctypes.c_size_t),
|
||||
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
|
||||
class WindowsJob:
|
||||
"""Kill spawned music/TTS processes automatically when the launcher exits."""
|
||||
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
||||
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
||||
|
||||
def __init__(self):
|
||||
self._handle = None
|
||||
if os.name != "nt":
|
||||
return
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
handle = kernel32.CreateJobObjectW(None, None)
|
||||
if not handle:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
||||
info.BasicLimitInformation.LimitFlags = self.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
ok = kernel32.SetInformationJobObject(
|
||||
handle,
|
||||
self.JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
ctypes.byref(info),
|
||||
ctypes.sizeof(info),
|
||||
)
|
||||
if not ok:
|
||||
error = ctypes.get_last_error()
|
||||
kernel32.CloseHandle(handle)
|
||||
raise ctypes.WinError(error)
|
||||
self._handle = handle
|
||||
|
||||
def assign(self, process: subprocess.Popen):
|
||||
if self._handle is None or os.name != "nt":
|
||||
return
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
if not kernel32.AssignProcessToJobObject(self._handle, process._handle):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
def close(self):
|
||||
if self._handle is None or os.name != "nt":
|
||||
return
|
||||
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
|
||||
|
||||
def _is_frozen() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _role_command(role: str, port: int, host: str) -> list[str]:
|
||||
if _is_frozen():
|
||||
return [sys.executable, "--role", role, "--port", str(port), "--host", host]
|
||||
return [sys.executable, str(Path(__file__).resolve()), "--role", role, "--port", str(port), "--host", host]
|
||||
|
||||
|
||||
def _assert_port_available(host: str, port: int):
|
||||
probe_host = "0.0.0.0" if host in {"", "::"} else host
|
||||
family = socket.AF_INET6 if ":" in probe_host else socket.AF_INET
|
||||
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||
sock.bind((probe_host, port))
|
||||
|
||||
|
||||
def _spawn_role(role: str, port: int, host: str, *, visible: bool = False) -> subprocess.Popen:
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = 0x00000010 if visible else 0x08000000 # CREATE_NEW_CONSOLE / CREATE_NO_WINDOW
|
||||
return subprocess.Popen(
|
||||
_role_command(role, port, host),
|
||||
cwd=APP_ROOT,
|
||||
stdout=None if visible else subprocess.DEVNULL,
|
||||
stderr=None if visible else subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
|
||||
async def _run_queue(host: str, port: int):
|
||||
import danmu_queue
|
||||
|
||||
await danmu_queue.main(host=host, port=port)
|
||||
|
||||
|
||||
async def _run_music(port: int):
|
||||
import music_monitor
|
||||
|
||||
await music_monitor.run_monitor(port)
|
||||
|
||||
|
||||
def _run_tts_monitor():
|
||||
import tts_monitor
|
||||
|
||||
sys.argv = [
|
||||
sys.argv[0],
|
||||
"--state-file",
|
||||
str(DATA_DIR / "tts_state.json"),
|
||||
]
|
||||
tts_monitor.main()
|
||||
|
||||
|
||||
def _stop_children(children: list[subprocess.Popen], timeout: float = 5.0):
|
||||
for child in children:
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
for child in children:
|
||||
if child.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
child.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait(timeout=timeout)
|
||||
|
||||
|
||||
async def _run_all(host: str, port: int):
|
||||
children: list[subprocess.Popen] = []
|
||||
job = WindowsJob()
|
||||
queue_task: asyncio.Task | None = None
|
||||
try:
|
||||
# Refuse stale/conflicting listeners before creating any helper process.
|
||||
_assert_port_available(host, port)
|
||||
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
|
||||
children.append(_spawn_role("music", port, host, visible=False))
|
||||
children.append(_spawn_role("tts", port, host, visible=True))
|
||||
for child in children:
|
||||
job.assign(child)
|
||||
await queue_task
|
||||
except Exception:
|
||||
if queue_task is not None and not queue_task.done():
|
||||
queue_task.cancel()
|
||||
await asyncio.gather(queue_task, return_exceptions=True)
|
||||
raise
|
||||
finally:
|
||||
_stop_children(children)
|
||||
job.close()
|
||||
|
||||
|
||||
def main():
|
||||
ensure_runtime_dirs()
|
||||
parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口")
|
||||
parser.add_argument("--role", choices=["all", "queue", "music", "tts"], default="all")
|
||||
parser.add_argument("--port", type=int, default=8086)
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
||||
args = parser.parse_args()
|
||||
|
||||
instance_lock = None
|
||||
if args.role in {"all", "queue"}:
|
||||
instance_lock = SingleInstanceLock()
|
||||
if not instance_lock.acquire():
|
||||
print("直播系统已经在运行,本次重复启动已拒绝。")
|
||||
return 2
|
||||
|
||||
try:
|
||||
if args.role == "tts":
|
||||
_run_tts_monitor()
|
||||
return 0
|
||||
if args.role == "music":
|
||||
asyncio.run(_run_music(args.port))
|
||||
return 0
|
||||
if args.role == "queue":
|
||||
asyncio.run(_run_queue(args.host, args.port))
|
||||
return 0
|
||||
asyncio.run(_run_all(args.host, args.port))
|
||||
return 0
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 10048 or getattr(exc, "errno", None) in {48, 98, 10048}:
|
||||
print(f"直播端口 {args.port} 已被占用,服务未启动,也未创建辅助进程。")
|
||||
return 3
|
||||
raise
|
||||
finally:
|
||||
if instance_lock is not None:
|
||||
instance_lock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Independent mpv audio player controlled through Windows JSON IPC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MpvPlayer:
|
||||
def __init__(
|
||||
self,
|
||||
exe_path: str | Path,
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
pipe_name: str = "",
|
||||
log_path: str | Path | None = None,
|
||||
):
|
||||
self.exe_path = Path(exe_path)
|
||||
self.logger = logger
|
||||
self.log_path = Path(log_path) if log_path else None
|
||||
pipe_name = pipe_name or f"live_streaming_mpv_{os.getpid()}"
|
||||
self.pipe_path = rf"\\.\pipe\{pipe_name}"
|
||||
self.process: subprocess.Popen | None = None
|
||||
self.current_url = ""
|
||||
self.current_metadata: dict[str, Any] = {}
|
||||
self.desired_state = "stopped"
|
||||
self.generation = 0
|
||||
self.started_at = 0.0
|
||||
self.last_progress = 0.0
|
||||
self.last_progress_at = 0.0
|
||||
self.last_snapshot_at = 0.0
|
||||
self.recovery_count = 0
|
||||
self._request_id = 0
|
||||
self._ipc_lock = asyncio.Lock()
|
||||
self._pipe_state_lock = threading.Lock()
|
||||
self._pipe = None
|
||||
|
||||
def available(self) -> bool:
|
||||
return self.exe_path.is_file()
|
||||
|
||||
def running(self) -> bool:
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
def update_exe_path(self, exe_path: str | Path) -> None:
|
||||
next_path = Path(exe_path)
|
||||
if next_path == self.exe_path:
|
||||
return
|
||||
if self.running():
|
||||
self.logger.warning(f"[mpv] 播放器路径已修改,将在进程下次重启后生效: {next_path}")
|
||||
self.exe_path = next_path
|
||||
|
||||
async def ensure_started(self) -> bool:
|
||||
if self.running():
|
||||
return True
|
||||
await asyncio.to_thread(self._reset_pipe_sync)
|
||||
if not self.available():
|
||||
self.logger.error(f"[mpv] 播放器不存在: {self.exe_path}")
|
||||
return False
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
||||
args = [
|
||||
str(self.exe_path),
|
||||
"--idle=yes",
|
||||
"--no-video",
|
||||
"--force-window=no",
|
||||
"--no-terminal",
|
||||
"--msg-level=all=warn",
|
||||
f"--input-ipc-server={self.pipe_path}",
|
||||
"--keep-open=no",
|
||||
"--audio-buffer=5",
|
||||
"--cache=yes",
|
||||
"--cache-secs=20",
|
||||
"--demuxer-max-bytes=50MiB",
|
||||
"--network-timeout=10",
|
||||
]
|
||||
if self.log_path:
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.append(f"--log-file={self.log_path}")
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
args,
|
||||
cwd=str(self.exe_path.parent),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
for _ in range(30):
|
||||
if not self.running():
|
||||
break
|
||||
if await self._command(["get_property", "idle-active"], retry=False) is not None:
|
||||
self.logger.info(f"[mpv] 播放服务已启动: {self.exe_path}")
|
||||
return True
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as exc:
|
||||
self.logger.error(f"[mpv] 启动失败: {exc}")
|
||||
return False
|
||||
|
||||
def _pipe_request_sync(self, command: list[Any], request_id: int) -> Any:
|
||||
request = json.dumps(
|
||||
{"command": command, "request_id": request_id},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8") + b"\n"
|
||||
pipe = None
|
||||
try:
|
||||
with self._pipe_state_lock:
|
||||
pipe = self._pipe
|
||||
if pipe is None or pipe.closed:
|
||||
pipe = open(self.pipe_path, "r+b", buffering=0)
|
||||
self._pipe = pipe
|
||||
pipe.write(request)
|
||||
deadline = time.time() + 2.5
|
||||
while time.time() < deadline:
|
||||
response = pipe.readline()
|
||||
if not response:
|
||||
continue
|
||||
payload = json.loads(response.decode("utf-8", errors="replace"))
|
||||
if payload.get("request_id") != request_id:
|
||||
continue
|
||||
if payload.get("error") != "success":
|
||||
return None
|
||||
if "data" not in payload:
|
||||
return True
|
||||
data = payload["data"]
|
||||
if data is None and command and command[0] != "get_property":
|
||||
return True
|
||||
return data
|
||||
except Exception:
|
||||
self._reset_pipe_sync(pipe)
|
||||
raise
|
||||
return None
|
||||
|
||||
def _reset_pipe_sync(self, expected_pipe=None) -> None:
|
||||
with self._pipe_state_lock:
|
||||
pipe = self._pipe
|
||||
if expected_pipe is not None and pipe is not expected_pipe:
|
||||
return
|
||||
self._pipe = None
|
||||
if pipe is not None:
|
||||
try:
|
||||
pipe.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _command(self, command: list[Any], *, retry: bool = True) -> Any:
|
||||
async with self._ipc_lock:
|
||||
attempts = 2 if retry else 1
|
||||
for attempt in range(attempts):
|
||||
self._request_id += 1
|
||||
request_id = self._request_id
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(self._pipe_request_sync, command, request_id),
|
||||
timeout=3.0,
|
||||
)
|
||||
except Exception:
|
||||
self._reset_pipe_sync()
|
||||
if attempt + 1 < attempts:
|
||||
await asyncio.sleep(0.15)
|
||||
return None
|
||||
|
||||
def _clear_current(self) -> None:
|
||||
self.current_url = ""
|
||||
self.current_metadata = {}
|
||||
self.started_at = 0.0
|
||||
self.last_progress = 0.0
|
||||
self.last_progress_at = 0.0
|
||||
self.recovery_count = 0
|
||||
|
||||
@staticmethod
|
||||
def _path_matches(expected: str, actual: str) -> bool:
|
||||
expected = str(expected or "").strip()
|
||||
actual = str(actual or "").strip()
|
||||
if not expected or not actual:
|
||||
return False
|
||||
if expected == actual:
|
||||
return True
|
||||
if expected.lower().startswith(("http://", "https://")):
|
||||
return False
|
||||
try:
|
||||
actual_path = actual[8:] if actual.lower().startswith("file:///") else actual
|
||||
return Path(expected).resolve() == Path(actual_path).resolve()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _wait_until_loaded(
|
||||
self,
|
||||
*,
|
||||
expected_generation: int,
|
||||
start_at: float,
|
||||
timeout: float = 12.0,
|
||||
) -> bool:
|
||||
deadline = time.time() + max(2.0, timeout)
|
||||
expected_url = self.current_url
|
||||
while time.time() < deadline:
|
||||
if expected_generation != self.generation or self.desired_state != "playing":
|
||||
return False
|
||||
if not self.running():
|
||||
return False
|
||||
path = await self._command(["get_property", "path"], retry=False)
|
||||
idle = await self._command(["get_property", "idle-active"], retry=False)
|
||||
if self._path_matches(expected_url, str(path or "")) and idle is False:
|
||||
if start_at > 0:
|
||||
seek_result = None
|
||||
seek_deadline = min(deadline, time.time() + 4.0)
|
||||
while time.time() < seek_deadline:
|
||||
duration = await self._command(["get_property", "duration"], retry=False)
|
||||
try:
|
||||
duration_value = max(0.0, float(duration or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
duration_value = 0.0
|
||||
if duration_value > 0:
|
||||
seek_result = await self._command(["seek", float(start_at), "absolute+exact"])
|
||||
if seek_result is not None:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
if seek_result is None:
|
||||
self.logger.warning(f"[mpv] 续播定位失败: {start_at:.1f} 秒")
|
||||
return False
|
||||
if await self._command(["set_property", "pause", False]) is None:
|
||||
return False
|
||||
progress_deadline = min(deadline, time.time() + 4.0)
|
||||
baseline = max(0.0, float(start_at or 0.0))
|
||||
while time.time() < progress_deadline:
|
||||
progress = await self._command(["get_property", "time-pos"], retry=False)
|
||||
duration = await self._command(["get_property", "duration"], retry=False)
|
||||
try:
|
||||
progress_value = max(0.0, float(progress or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
progress_value = 0.0
|
||||
try:
|
||||
duration_value = max(0.0, float(duration or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
duration_value = 0.0
|
||||
idle_now = await self._command(["get_property", "idle-active"], retry=False)
|
||||
if idle_now is False and (progress_value > 0.05 or duration_value > 0 or baseline > 0):
|
||||
now = time.time()
|
||||
self.started_at = now
|
||||
self.last_progress = max(baseline, progress_value)
|
||||
self.last_progress_at = now
|
||||
return True
|
||||
await asyncio.sleep(0.15)
|
||||
await asyncio.sleep(0.15)
|
||||
self.logger.warning(
|
||||
f"[mpv] 音频加载超时,未进入可播放状态: "
|
||||
f"{self.current_metadata.get('name') or self.current_url}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def _load_current(self, *, start_at: float, expected_generation: int) -> bool:
|
||||
if expected_generation != self.generation or self.desired_state != "playing" or not self.current_url:
|
||||
return False
|
||||
if not await self.ensure_started():
|
||||
return False
|
||||
if expected_generation != self.generation or self.desired_state != "playing":
|
||||
return False
|
||||
result = await self._command(["loadfile", self.current_url, "replace"])
|
||||
if result is None or expected_generation != self.generation or self.desired_state != "playing":
|
||||
return False
|
||||
return await self._wait_until_loaded(
|
||||
expected_generation=expected_generation,
|
||||
start_at=start_at,
|
||||
)
|
||||
|
||||
async def play(self, url: str, metadata: dict[str, Any], *, start_at: float = 0.0) -> bool:
|
||||
self.generation += 1
|
||||
generation = self.generation
|
||||
self.desired_state = "playing"
|
||||
self.current_url = str(url)
|
||||
self.current_metadata = dict(metadata)
|
||||
self.recovery_count = 0
|
||||
ok = await self._load_current(start_at=start_at, expected_generation=generation)
|
||||
if not ok and generation == self.generation:
|
||||
self.desired_state = "stopped"
|
||||
self._clear_current()
|
||||
return ok
|
||||
|
||||
async def pause(self) -> bool:
|
||||
if not self.current_url:
|
||||
return False
|
||||
self.desired_state = "paused"
|
||||
if not self.running():
|
||||
return True
|
||||
return await self._command(["set_property", "pause", True]) is not None
|
||||
|
||||
async def resume(self) -> bool:
|
||||
if not self.current_url:
|
||||
return False
|
||||
self.desired_state = "playing"
|
||||
self.last_progress_at = time.time()
|
||||
if not self.running():
|
||||
return await self._load_current(start_at=self.last_progress, expected_generation=self.generation)
|
||||
return await self._command(["set_property", "pause", False]) is not None
|
||||
|
||||
async def stop(self) -> bool:
|
||||
self.generation += 1
|
||||
self.desired_state = "stopped"
|
||||
self._clear_current()
|
||||
if not self.running():
|
||||
return True
|
||||
return await self._command(["stop"]) is not None
|
||||
|
||||
async def close(self) -> None:
|
||||
self.generation += 1
|
||||
self.desired_state = "stopped"
|
||||
self._clear_current()
|
||||
if self.running():
|
||||
await self._command(["quit"])
|
||||
await asyncio.sleep(0.15)
|
||||
if self.running():
|
||||
self.process.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(self.process.wait, 2)
|
||||
except Exception:
|
||||
if self.running():
|
||||
self.process.kill()
|
||||
self.process = None
|
||||
await asyncio.to_thread(self._reset_pipe_sync)
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if not self.running():
|
||||
return self._snapshot_payload(False, 0.0, 0.0, True, False, False, "", now)
|
||||
progress, duration, paused, idle, eof, path = await asyncio.gather(
|
||||
self._command(["get_property", "time-pos"]),
|
||||
self._command(["get_property", "duration"]),
|
||||
self._command(["get_property", "pause"]),
|
||||
self._command(["get_property", "idle-active"]),
|
||||
self._command(["get_property", "eof-reached"]),
|
||||
self._command(["get_property", "path"]),
|
||||
)
|
||||
try:
|
||||
progress_value = max(0.0, float(progress or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
progress_value = 0.0
|
||||
try:
|
||||
duration_value = max(0.0, float(duration or self.current_metadata.get("duration_sec", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
duration_value = 0.0
|
||||
idle_value = bool(idle) if idle is not None else not bool(self.current_url)
|
||||
paused_value = bool(paused)
|
||||
eof_value = bool(eof)
|
||||
playing = (
|
||||
self.desired_state == "playing"
|
||||
and bool(self.current_url)
|
||||
and not paused_value
|
||||
and not idle_value
|
||||
and not eof_value
|
||||
)
|
||||
if progress_value > self.last_progress + 0.2:
|
||||
self.last_progress = progress_value
|
||||
self.last_progress_at = now
|
||||
self.recovery_count = 0
|
||||
self.last_snapshot_at = now
|
||||
return self._snapshot_payload(
|
||||
playing,
|
||||
progress_value,
|
||||
duration_value,
|
||||
idle_value,
|
||||
paused_value,
|
||||
eof_value,
|
||||
str(path or ""),
|
||||
now,
|
||||
)
|
||||
|
||||
def _snapshot_payload(
|
||||
self,
|
||||
playing: bool,
|
||||
progress: float,
|
||||
duration: float,
|
||||
idle: bool,
|
||||
paused: bool,
|
||||
eof: bool,
|
||||
path: str,
|
||||
now: float,
|
||||
) -> dict[str, Any]:
|
||||
metadata = self.current_metadata
|
||||
return {
|
||||
"playing": playing,
|
||||
"paused": paused,
|
||||
"idle": idle,
|
||||
"eof": eof,
|
||||
"desired_state": self.desired_state,
|
||||
"generation": self.generation,
|
||||
"path": path,
|
||||
"current": {
|
||||
"id": str(metadata.get("id", "")),
|
||||
"title": metadata.get("name") or ("暂无歌曲" if idle else "正在加载"),
|
||||
"artist": metadata.get("artist") or "mpv",
|
||||
"cover": metadata.get("cover", ""),
|
||||
"cover_hash": metadata.get("cover_hash", ""),
|
||||
"duration": duration,
|
||||
"progress": progress,
|
||||
"source": "mpv",
|
||||
},
|
||||
"playlist": [],
|
||||
"requests": [],
|
||||
"monitor": {
|
||||
"online": self.running(),
|
||||
"source": "mpv.ipc",
|
||||
"platform": "mpv",
|
||||
"updated_at": now,
|
||||
"targets": [],
|
||||
"allow_all": False,
|
||||
},
|
||||
}
|
||||
|
||||
def mark_ended(self) -> None:
|
||||
self.generation += 1
|
||||
self.desired_state = "stopped"
|
||||
self._clear_current()
|
||||
|
||||
async def maintain(self, *, stall_seconds: float = 12.0) -> dict[str, Any]:
|
||||
"""Recover only unexpected failures while the desired state is playing."""
|
||||
if self.desired_state != "playing" or not self.current_url:
|
||||
return {"action": "none"}
|
||||
generation = self.generation
|
||||
now = time.time()
|
||||
if not self.running():
|
||||
progress = self.last_progress
|
||||
self.process = None
|
||||
if await self._load_current(start_at=progress, expected_generation=generation):
|
||||
self.logger.warning(f"[mpv] 进程退出后已从 {progress:.1f} 秒恢复")
|
||||
return {"action": "process_restarted", "progress": progress}
|
||||
return {"action": "failed", "reason": "process_restart_failed"}
|
||||
|
||||
state = await self.snapshot()
|
||||
if generation != self.generation or self.desired_state != "playing":
|
||||
return {"action": "superseded", "snapshot": state}
|
||||
progress = float((state.get("current") or {}).get("progress", 0) or 0)
|
||||
duration = float((state.get("current") or {}).get("duration", 0) or 0)
|
||||
effective_progress = max(progress, self.last_progress)
|
||||
near_end = duration > 0 and effective_progress >= max(0.0, duration - 2.0)
|
||||
unloaded_after_progress = (
|
||||
state.get("idle")
|
||||
and not str(state.get("path") or "")
|
||||
and effective_progress >= 0.5
|
||||
)
|
||||
if state.get("eof") or (state.get("idle") and near_end) or unloaded_after_progress:
|
||||
self.mark_ended()
|
||||
return {"action": "ended", "progress": effective_progress, "duration": duration, "snapshot": state}
|
||||
|
||||
loading_grace = now - self.started_at < 2.5
|
||||
if state.get("paused") and not loading_grace:
|
||||
if await self._command(["set_property", "pause", False]) is not None:
|
||||
self.last_progress_at = now
|
||||
self.logger.warning("[mpv] 检测到非预期暂停,已自动继续播放")
|
||||
return {"action": "resumed", "progress": progress, "snapshot": state}
|
||||
|
||||
if state.get("idle") and not loading_grace:
|
||||
return {"action": "reload_required", "reason": "unexpected_idle", "progress": progress, "snapshot": state}
|
||||
|
||||
if self.last_progress_at and not loading_grace and now - self.last_progress_at >= max(3.0, stall_seconds):
|
||||
self.recovery_count += 1
|
||||
if self.recovery_count == 1:
|
||||
await self._command(["set_property", "pause", False])
|
||||
self.last_progress_at = now
|
||||
return {"action": "unstalled", "progress": progress, "snapshot": state}
|
||||
return {"action": "reload_required", "reason": "stalled", "progress": progress, "snapshot": state}
|
||||
return {"action": "none", "snapshot": state}
|
||||
@@ -0,0 +1,965 @@
|
||||
"""
|
||||
音乐播放器 -> 直播间 UI 音乐状态同步
|
||||
|
||||
通过 Windows 10/11 的 SMTC(System Media Transport Controls)读取当前媒体会话,
|
||||
并推送到 BGI 直播间 Web 服务。默认偏向网易云音乐,但可在 config/config.json
|
||||
的 music_monitor 节点或命令行中配置目标播放器。
|
||||
|
||||
播放状态判断(参考 now-playing-service/NeteaseMusicService.cs):
|
||||
- 优先用 Windows 音频会话的峰值音量(volume>0 = Playing),最贴近"是否真在出声"
|
||||
- volume=0 但 UIA 进度最近 1.5s 内变化 → 仍视为 Playing(静音播放/拖进度条场景)
|
||||
- 否则 Paused
|
||||
|
||||
封面防闪:
|
||||
- 只在 SMTC 缩略图 hash 变化时才重写 music_cover.jpg(原子 tmp→replace)
|
||||
- 同一 hash 不重写文件,前端不会因文件变化触发 reload
|
||||
- 标题/歌手来自 cloudmusic 窗口标题(已稳定,不改)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
import aiohttp
|
||||
import winrt.windows.media.control as wmc
|
||||
import winrt.windows.storage.streams as streams
|
||||
|
||||
try:
|
||||
import uiautomation as uia
|
||||
except Exception:
|
||||
uia = None
|
||||
|
||||
# pycaw: 读取 Windows 音频会话峰值音量(参考 now-playing-service 的 CSCore.AudioMeterInformation)
|
||||
try:
|
||||
from pycaw.pycaw import AudioUtilities # type: ignore
|
||||
_PYCAW_OK = True
|
||||
except Exception:
|
||||
_PYCAW_OK = False
|
||||
|
||||
from core.runtime_paths import CONFIG_DIR, DATA_DIR, WEB_DIR, ensure_runtime_dirs
|
||||
|
||||
ensure_runtime_dirs()
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
MUSIC_FILE = DATA_DIR / "music_state.json"
|
||||
COVER_FILE = WEB_DIR / "music_cover.jpg"
|
||||
|
||||
DEFAULT_TARGETS = [
|
||||
"网易云音乐",
|
||||
"Netease",
|
||||
"CloudMusic",
|
||||
"cloudmusic",
|
||||
"YesPlayMusic",
|
||||
"Listen1",
|
||||
"QQMusic",
|
||||
"qqmusic",
|
||||
"spotify",
|
||||
]
|
||||
|
||||
DEFAULT_MONITOR_CONFIG = {
|
||||
"platform": "netease",
|
||||
"targets": ["网易云音乐", "Netease", "CloudMusic", "cloudmusic"],
|
||||
"allow_all": False,
|
||||
"interval_sec": 1.0,
|
||||
"holdover_ms": 1500,
|
||||
"prefer_playing": True,
|
||||
"keep_last_when_none": True,
|
||||
"cover_enabled": True,
|
||||
"auto_resume_enabled": False,
|
||||
"auto_resume_interval_sec": 3,
|
||||
"auto_resume_stall_sec": 10,
|
||||
"extra_filter": "",
|
||||
}
|
||||
|
||||
|
||||
def load_monitor_config() -> dict[str, Any]:
|
||||
cfg = dict(DEFAULT_MONITOR_CONFIG)
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data.get("music_monitor"), dict):
|
||||
cfg.update(data["music_monitor"])
|
||||
except Exception:
|
||||
pass
|
||||
targets = cfg.get("targets") or []
|
||||
if isinstance(targets, str):
|
||||
targets = [x.strip() for x in targets.replace(",", ",").split(",") if x.strip()]
|
||||
extra = str(cfg.get("extra_filter", "")).strip()
|
||||
if extra:
|
||||
targets.append(extra)
|
||||
cfg["targets"] = [str(x).strip() for x in targets if str(x).strip()]
|
||||
return cfg
|
||||
|
||||
|
||||
def session_identity(session) -> str:
|
||||
display = getattr(session, "source_app_display_name", "") or ""
|
||||
aumid = getattr(session, "source_app_user_model_id", "") or ""
|
||||
return f"{display} {aumid}".strip()
|
||||
|
||||
|
||||
def is_target_app(session, targets: list[str], allow_all: bool) -> bool:
|
||||
if allow_all or not targets:
|
||||
return True
|
||||
combined = session_identity(session).lower()
|
||||
return any(target.lower() in combined for target in targets)
|
||||
|
||||
|
||||
def playback_status_int(session) -> int:
|
||||
try:
|
||||
return int(session.get_playback_info().playback_status)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def is_playing(session) -> bool:
|
||||
return playback_status_int(session) == 4
|
||||
|
||||
|
||||
async def choose_session(mgr, cfg: dict[str, Any]):
|
||||
"""从全部媒体会话中选择最合适的目标会话。"""
|
||||
targets = cfg.get("targets", [])
|
||||
allow_all = bool(cfg.get("allow_all", False))
|
||||
prefer_playing = bool(cfg.get("prefer_playing", True))
|
||||
|
||||
sessions = list(mgr.get_sessions())
|
||||
candidates = [s for s in sessions if is_target_app(s, targets, allow_all)]
|
||||
if not candidates:
|
||||
current = mgr.get_current_session()
|
||||
if current and is_target_app(current, targets, allow_all):
|
||||
candidates = [current]
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 当前系统媒体会话通常最能代表用户正在操作的播放器;优先选择它,
|
||||
# 避免网易云残留多个 SMTC 会话时反复读到已停止更新的旧会话。
|
||||
current = mgr.get_current_session()
|
||||
if current and current in candidates and (not prefer_playing or is_playing(current)):
|
||||
return current
|
||||
|
||||
if prefer_playing:
|
||||
playing = [s for s in candidates if is_playing(s)]
|
||||
if playing:
|
||||
return playing[0]
|
||||
if current and current in candidates:
|
||||
return current
|
||||
return candidates[0]
|
||||
|
||||
|
||||
async def read_thumbnail(thumb_ref):
|
||||
if thumb_ref is None:
|
||||
return None
|
||||
try:
|
||||
stream = await thumb_ref.open_read_async()
|
||||
size = stream.size
|
||||
if size <= 0 or size > 5 * 1024 * 1024:
|
||||
return None
|
||||
buffer = streams.Buffer(size)
|
||||
await stream.read_async(buffer, size, streams.InputStreamOptions.READ_AHEAD)
|
||||
data = bytes(buffer)
|
||||
return data if len(data) > 100 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_cover_atomic(data: bytes, previous_hash: str = "") -> tuple[str, str]:
|
||||
cover_hash = hashlib.md5(data).hexdigest()
|
||||
if cover_hash == previous_hash and COVER_FILE.exists():
|
||||
return "/music_cover.jpg", cover_hash
|
||||
COVER_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = COVER_FILE.with_suffix(".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(COVER_FILE)
|
||||
return "/music_cover.jpg", cover_hash
|
||||
|
||||
|
||||
def parse_time_to_seconds(text: str) -> int | None:
|
||||
parts = str(text).strip().split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
return None
|
||||
try:
|
||||
nums = [int(x) for x in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if any(x < 0 for x in nums) or nums[-1] >= 60:
|
||||
return None
|
||||
if len(nums) == 2:
|
||||
return nums[0] * 60 + nums[1]
|
||||
if nums[1] >= 60:
|
||||
return None
|
||||
return nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||||
|
||||
|
||||
def parse_progress_text(text: str) -> tuple[int, int] | None:
|
||||
cleaned = str(text).replace(" ", "")
|
||||
m = re.search(r"(\d{1,2}:\d{2}(?::\d{2})?)\s*[/|/|]\s*(\d{1,2}:\d{2}(?::\d{2})?)", cleaned)
|
||||
if not m:
|
||||
return None
|
||||
current = parse_time_to_seconds(m.group(1))
|
||||
total = parse_time_to_seconds(m.group(2))
|
||||
if current is None or total is None or total <= 0 or current > total + 2:
|
||||
return None
|
||||
return max(0, current), max(0, total)
|
||||
|
||||
|
||||
def _cloudmusic_pids() -> list[int]:
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return []
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["tasklist", "/FI", "IMAGENAME eq cloudmusic.exe", "/FO", "CSV", "/NH"],
|
||||
text=True, encoding="gbk", errors="ignore", creationflags=0x08000000,
|
||||
)
|
||||
pids = []
|
||||
for line in output.splitlines():
|
||||
parts = [p.strip().strip('"') for p in line.split(",")]
|
||||
if len(parts) >= 2 and parts[0].lower() == "cloudmusic.exe":
|
||||
try:
|
||||
pids.append(int(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
return pids
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _window_titles_by_pids(pids: list[int]) -> list[str]:
|
||||
if not pids or not hasattr(ctypes, "windll"):
|
||||
return []
|
||||
titles = []
|
||||
user32 = ctypes.windll.user32
|
||||
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||||
def enum_proc(hwnd, lparam):
|
||||
try:
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
pid = ctypes.c_ulong()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value not in pids:
|
||||
return True
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
if length <= 0:
|
||||
return True
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
title = (buf.value or "").strip()
|
||||
if title:
|
||||
titles.append(title)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
try:
|
||||
user32.EnumWindows(EnumWindowsProc(enum_proc), 0)
|
||||
except Exception:
|
||||
pass
|
||||
return titles
|
||||
|
||||
|
||||
def read_netease_window_title() -> str:
|
||||
"""参考 now-playing-service:从 cloudmusic 进程窗口标题取“歌名 - 歌手”。"""
|
||||
for title in _window_titles_by_pids(_cloudmusic_pids()):
|
||||
if " - " in title and "MediaPlayer" not in title:
|
||||
return title.replace("/", " / ").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def read_netease_progress_uia() -> tuple[int, int] | None:
|
||||
"""参考 now-playing-service:只在 cloudmusic 播放窗口子树中解析 MM:SS / MM:SS。"""
|
||||
if uia is None:
|
||||
return None
|
||||
try:
|
||||
pids = set(_cloudmusic_pids())
|
||||
root = uia.GetRootControl()
|
||||
for win in root.GetChildren():
|
||||
try:
|
||||
pid = int(getattr(win, "ProcessId", 0) or 0)
|
||||
name = (getattr(win, "Name", "") or "")
|
||||
if pids and pid not in pids:
|
||||
continue
|
||||
if " - " not in name and "cloudmusic" not in (getattr(win, "ClassName", "") or "").lower():
|
||||
continue
|
||||
stack = list(win.GetChildren())
|
||||
deadline = time.time() + 0.3
|
||||
while stack and time.time() < deadline:
|
||||
ctrl = stack.pop(0)
|
||||
text_value = (getattr(ctrl, "Name", "") or "").strip()
|
||||
parsed = parse_progress_text(text_value)
|
||||
if parsed:
|
||||
return parsed
|
||||
try:
|
||||
stack.extend(ctrl.GetChildren())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_LAST_NETEASE_TITLE = ""
|
||||
_LAST_NETEASE_AT = 0.0
|
||||
_LAST_PROGRESS_SECONDS = -1
|
||||
_LAST_PROGRESS_CHANGE_AT = 0.0
|
||||
_LAST_VOLUME_PEAK = 0.0
|
||||
_LAST_PLAYING = False
|
||||
_LAST_COVER_HASH = ""
|
||||
_LAST_COVER_SOURCE = ""
|
||||
_COVER_CACHE: dict[str, tuple[str, str]] = {}
|
||||
_COVER_TASKS: dict[str, asyncio.Task] = {}
|
||||
_SMTC_DISABLED_UNTIL = 0.0
|
||||
_LAST_AUTO_RESUME_AT = 0.0
|
||||
_AUTO_RESUME_TRACK_KEY = ""
|
||||
_AUTO_RESUME_LAST_PROGRESS = -1.0
|
||||
_AUTO_RESUME_LAST_PROGRESS_AT = 0.0
|
||||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||||
_AUTO_RESUME_FAILURES = 0
|
||||
|
||||
|
||||
async def get_target_smtc_session(cfg: dict[str, Any]):
|
||||
mgr = await wmc.GlobalSystemMediaTransportControlsSessionManager.request_async()
|
||||
return await choose_session(mgr, cfg)
|
||||
|
||||
|
||||
async def try_resume_playback(cfg: dict[str, Any], force: bool = False) -> bool:
|
||||
"""通过 SMTC 尝试恢复播放;暂停/停滞时允许重复发送播放命令。"""
|
||||
global _SMTC_DISABLED_UNTIL
|
||||
if time.time() < _SMTC_DISABLED_UNTIL:
|
||||
return False
|
||||
|
||||
async def _inner() -> bool:
|
||||
session = await get_target_smtc_session(cfg)
|
||||
if session is None:
|
||||
return False
|
||||
if is_playing(session) and not force:
|
||||
return False
|
||||
result = await session.try_play_async()
|
||||
return bool(result)
|
||||
|
||||
def _run():
|
||||
return asyncio.run(_inner())
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.to_thread(_run), timeout=2.5)
|
||||
except Exception:
|
||||
_SMTC_DISABLED_UNTIL = time.time() + 5
|
||||
return False
|
||||
|
||||
|
||||
async def maybe_auto_resume(info: dict[str, Any] | None, cfg: dict[str, Any]) -> str:
|
||||
"""定期恢复明确暂停,也检测“状态为播放但进度长时间不动”的假播放。"""
|
||||
global _LAST_AUTO_RESUME_AT, _AUTO_RESUME_TRACK_KEY
|
||||
global _AUTO_RESUME_LAST_PROGRESS, _AUTO_RESUME_LAST_PROGRESS_AT
|
||||
global _AUTO_RESUME_PAUSED_SINCE, _AUTO_RESUME_FAILURES
|
||||
if not bool(cfg.get("auto_resume_enabled", False)):
|
||||
return ""
|
||||
if info is None or not info.get("title"):
|
||||
_AUTO_RESUME_TRACK_KEY = ""
|
||||
_AUTO_RESUME_LAST_PROGRESS = -1.0
|
||||
_AUTO_RESUME_LAST_PROGRESS_AT = 0.0
|
||||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||||
_AUTO_RESUME_FAILURES = 0
|
||||
return ""
|
||||
|
||||
now = time.time()
|
||||
title = str(info.get("title") or "").strip()
|
||||
artist = str(info.get("artist") or "").strip()
|
||||
track_key = f"{title}\n{artist}"
|
||||
progress = max(0.0, float(info.get("progress") or 0))
|
||||
duration = max(0.0, float(info.get("duration") or 0))
|
||||
playing = bool(info.get("playing"))
|
||||
|
||||
if track_key != _AUTO_RESUME_TRACK_KEY:
|
||||
_AUTO_RESUME_TRACK_KEY = track_key
|
||||
_AUTO_RESUME_LAST_PROGRESS = progress
|
||||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||||
_AUTO_RESUME_PAUSED_SINCE = 0.0 if playing else now
|
||||
_AUTO_RESUME_FAILURES = 0
|
||||
return ""
|
||||
|
||||
if playing:
|
||||
_AUTO_RESUME_PAUSED_SINCE = 0.0
|
||||
elif not _AUTO_RESUME_PAUSED_SINCE:
|
||||
_AUTO_RESUME_PAUSED_SINCE = now
|
||||
|
||||
if abs(progress - _AUTO_RESUME_LAST_PROGRESS) >= 0.5:
|
||||
_AUTO_RESUME_LAST_PROGRESS = progress
|
||||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||||
_AUTO_RESUME_FAILURES = 0
|
||||
return ""
|
||||
|
||||
interval_sec = max(1.0, float(cfg.get("auto_resume_interval_sec", 3) or 3))
|
||||
stall_sec = max(interval_sec, float(cfg.get("auto_resume_stall_sec", 10) or 10))
|
||||
stalled = (
|
||||
playing
|
||||
and progress > 0
|
||||
and (duration <= 0 or progress < max(0, duration - 2))
|
||||
and now - _AUTO_RESUME_LAST_PROGRESS_AT >= stall_sec
|
||||
)
|
||||
if playing and not stalled:
|
||||
return ""
|
||||
if now - _LAST_AUTO_RESUME_AT < interval_sec:
|
||||
return ""
|
||||
|
||||
_LAST_AUTO_RESUME_AT = now
|
||||
# 明确暂停也强制发送 try_play,避免 SMTC 状态缓存或会话切换导致第一次命令被吞掉。
|
||||
resumed = await try_resume_playback(cfg, force=True)
|
||||
if resumed:
|
||||
_AUTO_RESUME_LAST_PROGRESS_AT = now
|
||||
_AUTO_RESUME_FAILURES = 0
|
||||
return "stalled" if stalled else "paused"
|
||||
|
||||
_AUTO_RESUME_FAILURES += 1
|
||||
# 连续恢复失败时缩短 SMTC 禁用窗口,下一轮重新获取会话并继续尝试。
|
||||
if _AUTO_RESUME_FAILURES >= 2:
|
||||
global _SMTC_DISABLED_UNTIL
|
||||
_SMTC_DISABLED_UNTIL = min(_SMTC_DISABLED_UNTIL, now + 1)
|
||||
return ""
|
||||
|
||||
|
||||
async def read_smtc_snapshot(cfg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""在隔离线程里读取 SMTC,避免 WinRT 偶发卡死拖住 MusicMonitor 主循环。"""
|
||||
global _SMTC_DISABLED_UNTIL
|
||||
if time.time() < _SMTC_DISABLED_UNTIL:
|
||||
return None
|
||||
|
||||
async def _inner():
|
||||
session = await get_target_smtc_session(cfg)
|
||||
if session is None:
|
||||
return None
|
||||
props = await session.try_get_media_properties_async()
|
||||
playback = session.get_playback_info()
|
||||
timeline = session.get_timeline_properties()
|
||||
thumb_data = None
|
||||
if bool(cfg.get("cover_enabled", True)):
|
||||
thumb_data = await read_thumbnail(props.thumbnail)
|
||||
return {
|
||||
"title": props.title or "",
|
||||
"artist": props.artist or "",
|
||||
"duration": max(0, timeline.end_time.total_seconds()),
|
||||
"progress": max(0, timeline.position.total_seconds()),
|
||||
"playing": int(playback.playback_status) == 4,
|
||||
"source": session_identity(session) or "smtc",
|
||||
"thumb_data": thumb_data,
|
||||
}
|
||||
|
||||
def _run():
|
||||
return asyncio.run(_inner())
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.to_thread(_run), timeout=2.5)
|
||||
except Exception:
|
||||
_SMTC_DISABLED_UNTIL = time.time() + 5
|
||||
return None
|
||||
|
||||
|
||||
def _get_cloudmusic_audio_peak() -> float:
|
||||
"""参考 now-playing-service: 累加 cloudmusic 所有音频会话的峰值音量。
|
||||
volume>0 = 真在出声 = Playing。pycaw 不可用时返回 -1 表示未知。"""
|
||||
if not _PYCAW_OK:
|
||||
return -1.0
|
||||
try:
|
||||
from pycaw.pycaw import IAudioMeterInformation # type: ignore
|
||||
total = 0.0
|
||||
sessions = AudioUtilities.GetAllSessions()
|
||||
for sess in sessions:
|
||||
try:
|
||||
proc = getattr(sess, "Process", None)
|
||||
if proc is None:
|
||||
continue
|
||||
# pycaw 的 Process.name 是方法不是属性, 要调用
|
||||
name_attr = getattr(proc, "name", None)
|
||||
if callable(name_attr):
|
||||
pname = name_attr()
|
||||
else:
|
||||
pname = str(name_attr or "")
|
||||
pname = (pname or "").lower()
|
||||
if "cloudmusic" in pname:
|
||||
meter = sess._ctl.QueryInterface(IAudioMeterInformation)
|
||||
total += meter.GetPeakValue()
|
||||
except Exception:
|
||||
continue
|
||||
return total
|
||||
except Exception:
|
||||
return -1.0
|
||||
|
||||
|
||||
def _decide_playing(volume_peak: float, progress_changed_recently: bool, holdover_sec: float) -> bool:
|
||||
"""参考 now-playing-service: volume>0 → Playing; volume=0 但进度最近变化 → Playing; 否则保持/暂停。"""
|
||||
global _LAST_PLAYING
|
||||
if volume_peak > 0.00001:
|
||||
_LAST_PLAYING = True
|
||||
return True
|
||||
if volume_peak < 0:
|
||||
# pycaw 不可用,回退到进度判断
|
||||
if progress_changed_recently:
|
||||
_LAST_PLAYING = True
|
||||
return True
|
||||
_LAST_PLAYING = False
|
||||
return False
|
||||
# volume=0
|
||||
if progress_changed_recently:
|
||||
# 进度在动但没声音 → 静音播放,仍算 Playing
|
||||
_LAST_PLAYING = True
|
||||
return True
|
||||
_LAST_PLAYING = False
|
||||
return False
|
||||
|
||||
|
||||
def _split_title_artist(window_title: str) -> tuple[str, str]:
|
||||
title = (window_title or "").strip()
|
||||
if " - " in title:
|
||||
song, artist = title.split(" - ", 1)
|
||||
return song.strip() or title, artist.strip()
|
||||
return title, ""
|
||||
|
||||
|
||||
import websockets as _ws_mod
|
||||
|
||||
def fiber_store_extract_js() -> str:
|
||||
return r'''
|
||||
function _ensureStore() {
|
||||
try {
|
||||
if (window._reduxStore) return true;
|
||||
const rootEl = document.querySelector('#root');
|
||||
const root = window._fiberRoot || (rootEl && rootEl._reactRootContainer && rootEl._reactRootContainer._internalRoot);
|
||||
if (!root) return false;
|
||||
let queue = [root.current || root];
|
||||
let visited = 0;
|
||||
while (queue.length > 0) {
|
||||
let node = queue.shift();
|
||||
if (!node) continue;
|
||||
visited++;
|
||||
if (visited > 20000) break;
|
||||
if (node.memoizedProps && node.memoizedProps.store) { window._reduxStore = node.memoizedProps.store; return true; }
|
||||
if (node.stateNode && node.stateNode.store) { window._reduxStore = node.stateNode.store; return true; }
|
||||
let child = node.child;
|
||||
while (child) { queue.push(child); child = child.sibling; }
|
||||
}
|
||||
return false;
|
||||
} catch(err) { return false; }
|
||||
}
|
||||
'''
|
||||
|
||||
async def get_cdp_ws_url(port: int = 9222) -> str:
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1.2)) as session:
|
||||
async with session.get(f"http://127.0.0.1:{port}/json") as resp:
|
||||
if resp.status != 200:
|
||||
return ""
|
||||
targets = await resp.json(content_type=None)
|
||||
for target in targets:
|
||||
text = (str(target.get("url", "")) + " " + str(target.get("title", ""))).lower()
|
||||
if target.get("type") == "page" and target.get("webSocketDebuggerUrl") and ("orpheus" in text or "music.163.com" in text):
|
||||
return target.get("webSocketDebuggerUrl", "")
|
||||
for target in targets:
|
||||
if target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
|
||||
return target.get("webSocketDebuggerUrl", "")
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
async def read_netease_cdp_state(cfg: dict[str, Any], previous_hash: str = "") -> dict[str, Any] | None:
|
||||
"""通过 CDP 读网易云 Redux 状态。只取 title/artist/progress/duration/picUrl,
|
||||
不取 playing(playing 交给音量峰值判断)。"""
|
||||
port = int(((cfg.get("request_player") or {}).get("cdp_port", 9222)) or 9222)
|
||||
ws_url = await get_cdp_ws_url(port)
|
||||
if not ws_url:
|
||||
return None
|
||||
script = fiber_store_extract_js() + r'''
|
||||
(function(){
|
||||
if(!_ensureStore()) return null;
|
||||
const state = window._reduxStore.getState();
|
||||
const playing = state.playing || {};
|
||||
const list = (state.playingList && state.playingList.curPlayingList) || [];
|
||||
const id = playing.resourceTrackId || playing.onlineResourceId || playing.resourceId || playing.trackId;
|
||||
function normId(x){ return x == null ? '' : String(x); }
|
||||
let item = null;
|
||||
if(id) item = list.find(x => normId(x.id || x.trackId || x.resourceId) === normId(id));
|
||||
if(!item && list.length === 1) item = list[0];
|
||||
const track = (item && (item.track || item.resource || item)) || {};
|
||||
const artists = track.artists || track.ar || item?.artists || item?.ar || [];
|
||||
let artist = '';
|
||||
if(Array.isArray(artists)) artist = artists.map(a => a && a.name ? a.name : '').filter(Boolean).join('/');
|
||||
else if(typeof artists === 'string') artist = artists;
|
||||
const album = track.album || track.al || item?.album || item?.al || {};
|
||||
const picUrl = album.picUrl || album.blurPicUrl || track.picUrl || item?.picUrl || '';
|
||||
const durationMs = Number(track.duration || track.dt || item?.duration || item?.dt || 0);
|
||||
const positionMs = Number(playing.position || playing.currentTime || playing.progress || 0);
|
||||
return {
|
||||
id: normId(id || item?.id || track.id),
|
||||
title: track.name || item?.name || '',
|
||||
artist: artist || '',
|
||||
picUrl: picUrl || '',
|
||||
duration: durationMs > 10000 ? durationMs / 1000 : durationMs,
|
||||
progress: positionMs > 10000 ? positionMs / 1000 : positionMs,
|
||||
};
|
||||
})()
|
||||
'''
|
||||
try:
|
||||
async with _ws_mod.connect(ws_url, open_timeout=1.5, close_timeout=0.5) as ws:
|
||||
await ws.send(json.dumps({
|
||||
"id": 1,
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {"expression": script, "returnByValue": True, "awaitPromise": True},
|
||||
}))
|
||||
deadline = time.time() + 2
|
||||
while time.time() < deadline:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - time.time()))
|
||||
msg = json.loads(raw)
|
||||
if msg.get("id") != 1:
|
||||
continue
|
||||
value = (((msg.get("result") or {}).get("result") or {}).get("value"))
|
||||
if isinstance(value, dict) and (value.get("title") or value.get("id")):
|
||||
return value
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def download_cover_url(pic_url: str, previous_hash: str = "") -> tuple[str, str]:
|
||||
global _LAST_COVER_SOURCE
|
||||
if not pic_url:
|
||||
return "", previous_hash
|
||||
if pic_url.startswith("http://"):
|
||||
pic_url = "https://" + pic_url[7:]
|
||||
if pic_url == _LAST_COVER_SOURCE and previous_hash and COVER_FILE.exists():
|
||||
return "/music_cover.jpg", previous_hash
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session:
|
||||
async with session.get(pic_url) as resp:
|
||||
if resp.status != 200:
|
||||
return "", previous_hash
|
||||
data = await resp.read()
|
||||
if data:
|
||||
cover_url, cover_hash = write_cover_atomic(data, previous_hash)
|
||||
_LAST_COVER_SOURCE = pic_url
|
||||
return cover_url, cover_hash
|
||||
except Exception:
|
||||
return "", previous_hash
|
||||
return "", previous_hash
|
||||
|
||||
|
||||
def cover_cache_key(title: str, artist: str) -> str:
|
||||
return re.sub(r"\s+", " ", f"{title} - {artist}".strip().lower())
|
||||
|
||||
|
||||
async def fetch_cover_task(key: str, title: str, artist: str):
|
||||
"""后台补封面:失败也不能影响 MusicMonitor 主循环。"""
|
||||
try:
|
||||
keyword = " ".join(x for x in [title, artist] if x).strip()
|
||||
if not keyword:
|
||||
return
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://music.163.com/",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": "os=pc; appver=2.9.8;",
|
||||
"X-Real-IP": "118.88.88.88",
|
||||
"X-Forwarded-For": "118.88.88.88",
|
||||
}
|
||||
async with aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=4)) as session:
|
||||
async with session.post(
|
||||
"https://music.163.com/api/search/get/web",
|
||||
data={"s": keyword, "type": "1", "limit": "1", "offset": "0"},
|
||||
) as resp:
|
||||
result = await resp.json(content_type=None)
|
||||
songs = ((result.get("result") or {}).get("songs") or [])
|
||||
if not songs:
|
||||
return
|
||||
album = songs[0].get("album") or {}
|
||||
pic_url = album.get("picUrl") or album.get("blurPicUrl") or ""
|
||||
if not pic_url:
|
||||
return
|
||||
if pic_url.startswith("http://"):
|
||||
pic_url = "https://" + pic_url[7:]
|
||||
async with session.get(pic_url) as img_resp:
|
||||
if img_resp.status != 200:
|
||||
return
|
||||
data = await img_resp.read()
|
||||
if data:
|
||||
_COVER_CACHE[key] = write_cover_atomic(data)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_COVER_TASKS.pop(key, None)
|
||||
|
||||
|
||||
def apply_cached_or_schedule_cover(info: dict[str, Any], cfg: dict[str, Any]):
|
||||
if not bool(cfg.get("cover_enabled", True)):
|
||||
return
|
||||
key = cover_cache_key(info.get("title", ""), info.get("artist", ""))
|
||||
if not key:
|
||||
return
|
||||
cached = _COVER_CACHE.get(key)
|
||||
if cached:
|
||||
info["cover"], info["cover_hash"] = cached
|
||||
return
|
||||
task = _COVER_TASKS.get(key)
|
||||
if task is None or task.done():
|
||||
try:
|
||||
_COVER_TASKS[key] = asyncio.create_task(fetch_cover_task(key, info.get("title", ""), info.get("artist", "")))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def apply_netease_window_fallback(info: dict[str, Any], cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""用窗口标题修正歌曲名/艺人,用音量峰值+进度变化判断播放状态。
|
||||
参考 now-playing-service/NeteaseMusicService.cs。"""
|
||||
global _LAST_NETEASE_TITLE, _LAST_NETEASE_AT, _LAST_PROGRESS_SECONDS, _LAST_PROGRESS_CHANGE_AT
|
||||
holdover = max(0, int(cfg.get("holdover_ms", 1500))) / 1000
|
||||
win_title = read_netease_window_title()
|
||||
now = time.time()
|
||||
if win_title:
|
||||
_LAST_NETEASE_TITLE = win_title
|
||||
_LAST_NETEASE_AT = now
|
||||
elif _LAST_NETEASE_TITLE and now - _LAST_NETEASE_AT <= holdover:
|
||||
win_title = _LAST_NETEASE_TITLE
|
||||
if win_title:
|
||||
title, artist = _split_title_artist(win_title)
|
||||
if title:
|
||||
info["title"] = title
|
||||
if artist:
|
||||
info["artist"] = artist
|
||||
info["source"] = "cloudmusic.window"
|
||||
# 进度
|
||||
parsed = read_netease_progress_uia()
|
||||
if parsed:
|
||||
progress, duration = parsed
|
||||
info["progress"] = progress
|
||||
info["duration"] = duration
|
||||
if progress != _LAST_PROGRESS_SECONDS:
|
||||
_LAST_PROGRESS_SECONDS = progress
|
||||
_LAST_PROGRESS_CHANGE_AT = now
|
||||
progress_changed_recently = (now - _LAST_PROGRESS_CHANGE_AT) <= holdover
|
||||
# 播放状态:音量峰值优先,进度兜底
|
||||
volume_peak = _get_cloudmusic_audio_peak()
|
||||
info["playing"] = _decide_playing(volume_peak, progress_changed_recently, holdover)
|
||||
return info
|
||||
|
||||
|
||||
async def get_media_info(cfg: dict[str, Any], previous_cover_hash: str = ""):
|
||||
"""网易云已开启 SMTC 后:优先信任 SMTC 的标题、封面、播放状态;CDP/窗口标题只做兜底。"""
|
||||
is_netease = any("cloudmusic" in str(t).lower() for t in cfg.get("targets", []))
|
||||
|
||||
smtc = await read_smtc_snapshot(cfg)
|
||||
|
||||
info = {
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"duration": 0,
|
||||
"progress": 0,
|
||||
"playing": False,
|
||||
"cover": "",
|
||||
"cover_hash": previous_cover_hash,
|
||||
"source": "smtc",
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
cover_url = ""
|
||||
cover_hash = previous_cover_hash
|
||||
|
||||
if smtc is not None:
|
||||
info["title"] = smtc.get("title", "")
|
||||
info["artist"] = smtc.get("artist", "")
|
||||
info["duration"] = float(smtc.get("duration") or 0)
|
||||
info["progress"] = float(smtc.get("progress") or 0)
|
||||
info["source"] = smtc.get("source") or "smtc"
|
||||
info["playing"] = bool(smtc.get("playing"))
|
||||
thumb_data = smtc.get("thumb_data")
|
||||
if thumb_data:
|
||||
cover_url, cover_hash = write_cover_atomic(thumb_data, previous_cover_hash)
|
||||
|
||||
if is_netease:
|
||||
# SMTC 现在是主数据源;如果 SMTC 某些字段缺失,再用 CDP/窗口标题补齐。
|
||||
# 现在网易云已开启 SMTC,音乐显示主链路不再碰 CDP/WebSocket,避免卡主循环。
|
||||
# 如果 SMTC 暂时没给标题,再用窗口标题兜底;封面则保留上一轮,不再网络下载。
|
||||
if not info.get("title"):
|
||||
info = apply_netease_window_fallback(info, cfg)
|
||||
|
||||
if not info.get("title"):
|
||||
return None
|
||||
elif smtc is None:
|
||||
return None
|
||||
|
||||
# 封面绝不因为某一轮没读到就清空,避免前台闪烁。
|
||||
if cover_url:
|
||||
info["cover"] = cover_url
|
||||
elif previous_cover_hash and COVER_FILE.exists():
|
||||
info["cover"] = "/music_cover.jpg"
|
||||
else:
|
||||
# SMTC/窗口标题没有封面时,按“歌名 + 歌手”异步补封面;成功后下一轮自动显示。
|
||||
apply_cached_or_schedule_cover(info, cfg)
|
||||
if not info.get("cover"):
|
||||
info["cover"] = ""
|
||||
info["cover_hash"] = info.get("cover_hash") or cover_hash
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def default_state() -> dict[str, Any]:
|
||||
return {
|
||||
"playing": False,
|
||||
"current": {
|
||||
"title": "暂无歌曲",
|
||||
"artist": "未接入音乐源",
|
||||
"cover": "",
|
||||
"duration": 0,
|
||||
"progress": 0,
|
||||
"source": "",
|
||||
},
|
||||
"playlist": [],
|
||||
"requests": [],
|
||||
"monitor": {"online": False, "source": ""},
|
||||
}
|
||||
|
||||
|
||||
def load_state() -> dict[str, Any]:
|
||||
if MUSIC_FILE.exists():
|
||||
try:
|
||||
state = json.loads(MUSIC_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(state, dict):
|
||||
return state
|
||||
except Exception:
|
||||
pass
|
||||
return default_state()
|
||||
|
||||
|
||||
async def save_state(state: dict[str, Any]):
|
||||
MUSIC_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = MUSIC_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(MUSIC_FILE)
|
||||
|
||||
|
||||
async def post_state(state: dict[str, Any], api_url: str) -> bool:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(api_url, json=state) as resp:
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def run_monitor(port: int, interval: float | None = None, cli_targets=None, allow_all: bool | None = None):
|
||||
api_url = f"http://localhost:{port}/api/music"
|
||||
state = load_state()
|
||||
last_info_time = 0.0
|
||||
print(f"[MusicMonitor] start api={api_url}", flush=True)
|
||||
|
||||
while True:
|
||||
cfg = load_monitor_config()
|
||||
if interval is not None:
|
||||
cfg["interval_sec"] = interval
|
||||
if cli_targets:
|
||||
cfg["targets"] = cli_targets
|
||||
if allow_all is not None:
|
||||
cfg["allow_all"] = allow_all
|
||||
|
||||
sleep_sec = max(0.3, float(cfg.get("interval_sec", 1.0)))
|
||||
holdover_sec = max(0, int(cfg.get("holdover_ms", 1500))) / 1000
|
||||
keep_last = bool(cfg.get("keep_last_when_none", True))
|
||||
|
||||
try:
|
||||
previous_cover_hash = ((state.get("current") or {}).get("cover_hash") or "")
|
||||
info = await get_media_info(cfg, previous_cover_hash)
|
||||
now = time.time()
|
||||
resume_reason = await maybe_auto_resume(info, cfg) if info is not None else ""
|
||||
if resume_reason:
|
||||
info["playing"] = True
|
||||
reason_text = "进度停滞" if resume_reason == "stalled" else "检测到暂停"
|
||||
print(f"[MusicMonitor] auto resume playback ({reason_text})", flush=True)
|
||||
|
||||
if info is None:
|
||||
monitor = state.setdefault("monitor", {})
|
||||
monitor["online"] = False
|
||||
monitor["updated_at"] = now
|
||||
monitor["source"] = ""
|
||||
monitor["platform"] = cfg.get("platform", "")
|
||||
monitor["targets"] = cfg.get("targets", [])
|
||||
monitor["allow_all"] = bool(cfg.get("allow_all", False))
|
||||
within_holdover = bool(last_info_time) and now - last_info_time <= holdover_sec
|
||||
if keep_last and within_holdover:
|
||||
# SMTC 偶发丢一帧时短暂保留,避免页面闪烁。
|
||||
pass
|
||||
else:
|
||||
# 超过保留时间后不能继续把旧歌曲伪装成“已暂停”。
|
||||
# 仅清理播放器状态,保留点歌队列等独立数据。
|
||||
empty = default_state()
|
||||
state["playing"] = False
|
||||
state["current"] = empty["current"]
|
||||
if not keep_last:
|
||||
state["playlist"] = []
|
||||
else:
|
||||
last_info_time = now
|
||||
state["playing"] = info["playing"]
|
||||
prev_cover = (state.get("current") or {}).get("cover", "")
|
||||
prev_cover_hash = (state.get("current") or {}).get("cover_hash", "")
|
||||
# 封面: 新值优先, 空值保留旧值(防闪)
|
||||
new_cover = info.get("cover", "") or prev_cover
|
||||
new_cover_hash = info.get("cover_hash", "") or prev_cover_hash
|
||||
# 如果异步封面任务刚完成,本轮 info 可能尚未带 cover;这里再查一次缓存。
|
||||
if not new_cover:
|
||||
cached = _COVER_CACHE.get(cover_cache_key(info.get("title", ""), info.get("artist", "")))
|
||||
if cached:
|
||||
new_cover, new_cover_hash = cached
|
||||
state["current"] = {
|
||||
"title": info["title"],
|
||||
"artist": info["artist"],
|
||||
"cover": new_cover,
|
||||
"cover_hash": new_cover_hash,
|
||||
"duration": info["duration"],
|
||||
"progress": info["progress"],
|
||||
"source": info["source"],
|
||||
}
|
||||
state["monitor"] = {
|
||||
"online": True,
|
||||
"source": info["source"],
|
||||
"platform": cfg.get("platform", ""),
|
||||
"updated_at": info["updated_at"],
|
||||
"targets": cfg.get("targets", []),
|
||||
"allow_all": bool(cfg.get("allow_all", False)),
|
||||
}
|
||||
|
||||
ok = await post_state(state, api_url)
|
||||
if not ok:
|
||||
await save_state(state)
|
||||
|
||||
cur = state.get("current") or {}
|
||||
print(f"[MusicMonitor] {cur.get('title', '')} - {cur.get('artist', '')} "
|
||||
f"({cur.get('progress', 0):.0f}s/{cur.get('duration', 0):.0f}s) "
|
||||
f"playing={state.get('playing')} source={cur.get('source', '')}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[MusicMonitor] error: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
await asyncio.sleep(sleep_sec)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="BGI 直播间音乐状态同步")
|
||||
parser.add_argument("--port", type=int, default=8086, help="本地 Web 服务端口号")
|
||||
parser.add_argument("--interval", type=float, default=None, help="轮询间隔(秒),默认读取 config/config.json")
|
||||
parser.add_argument("--allow-all", action="store_true", help="允许同步任意媒体会话(适合网页版播放器)")
|
||||
parser.add_argument("--filter", default="", help="额外过滤关键字,匹配 source_app_display_name/aumid")
|
||||
args = parser.parse_args()
|
||||
|
||||
targets = None
|
||||
if args.filter:
|
||||
cfg = load_monitor_config()
|
||||
targets = list(cfg.get("targets", [])) + [args.filter]
|
||||
asyncio.run(run_monitor(args.port, args.interval, targets, True if args.allow_all else None))
|
||||
@@ -0,0 +1,320 @@
|
||||
"""网易云音乐二维码登录会话。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import http.cookiejar
|
||||
import http.cookies
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
try:
|
||||
from .netease_resolver import NeteaseResolver
|
||||
except ImportError:
|
||||
from netease_resolver import NeteaseResolver
|
||||
|
||||
|
||||
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
_WEAPI_NONCE = b"0CoJUm6Qyw8W8jud"
|
||||
_WEAPI_IV = b"0102030405060708"
|
||||
_WEAPI_PUBLIC_EXPONENT = 0x10001
|
||||
_WEAPI_MODULUS = int(
|
||||
"00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725"
|
||||
"152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312"
|
||||
"ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424"
|
||||
"d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8"
|
||||
"e7",
|
||||
16,
|
||||
)
|
||||
_SECRET_ALPHABET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def _aes_encrypt_base64(content: bytes, key: bytes) -> bytes:
|
||||
padding = AES.block_size - (len(content) % AES.block_size)
|
||||
padded = content + bytes([padding]) * padding
|
||||
encrypted = AES.new(key, AES.MODE_CBC, _WEAPI_IV).encrypt(padded)
|
||||
return base64.b64encode(encrypted)
|
||||
|
||||
|
||||
def _weapi_form(data: dict[str, Any], *, secret_key: str | None = None) -> dict[str, str]:
|
||||
secret = secret_key or "".join(secrets.choice(_SECRET_ALPHABET) for _ in range(16))
|
||||
if len(secret.encode("ascii")) != 16:
|
||||
raise ValueError("weapi secret key must be 16 ASCII bytes")
|
||||
serialized = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
first_pass = _aes_encrypt_base64(serialized, _WEAPI_NONCE)
|
||||
params = _aes_encrypt_base64(first_pass, secret.encode("ascii")).decode("ascii")
|
||||
reversed_secret = int.from_bytes(secret[::-1].encode("ascii"), "big")
|
||||
enc_sec_key = format(
|
||||
pow(reversed_secret, _WEAPI_PUBLIC_EXPONENT, _WEAPI_MODULUS),
|
||||
"x",
|
||||
).zfill(256)
|
||||
return {"params": params, "encSecKey": enc_sec_key}
|
||||
|
||||
|
||||
def _render_qr_png(content: str) -> bytes:
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("缺少 qrcode 依赖,请重新安装 requirements.txt") from exc
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=3,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str,
|
||||
*,
|
||||
opener: urllib.request.OpenerDirector | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], Any]:
|
||||
encoded_data = None
|
||||
if data is not None:
|
||||
encoded_data = urllib.parse.urlencode(data).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=encoded_data,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Referer": "https://music.163.com/",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
response = (opener or urllib.request.build_opener()).open(request, timeout=15)
|
||||
payload = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("网易云登录接口返回格式无效")
|
||||
return payload, response
|
||||
|
||||
|
||||
def _request_weapi_json(
|
||||
url: str,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
opener: urllib.request.OpenerDirector,
|
||||
secret_key: str | None = None,
|
||||
) -> tuple[dict[str, Any], Any]:
|
||||
return _request_json(
|
||||
url,
|
||||
opener=opener,
|
||||
data=_weapi_form(data, secret_key=secret_key),
|
||||
)
|
||||
|
||||
|
||||
def _cookie_values(jar: http.cookiejar.CookieJar, response: Any, payload: dict[str, Any]) -> dict[str, str]:
|
||||
values = {cookie.name: cookie.value for cookie in jar}
|
||||
headers = getattr(response, "headers", None)
|
||||
raw_headers = headers.get_all("Set-Cookie", []) if headers and hasattr(headers, "get_all") else []
|
||||
for raw_header in raw_headers:
|
||||
parsed = http.cookies.SimpleCookie()
|
||||
try:
|
||||
parsed.load(raw_header)
|
||||
except http.cookies.CookieError:
|
||||
continue
|
||||
values.update({name: morsel.value for name, morsel in parsed.items()})
|
||||
for raw_cookie in (
|
||||
payload.get("cookie"),
|
||||
(payload.get("data") or {}).get("cookie") if isinstance(payload.get("data"), dict) else None,
|
||||
):
|
||||
if not raw_cookie:
|
||||
continue
|
||||
parsed = http.cookies.SimpleCookie()
|
||||
try:
|
||||
parsed.load(str(raw_cookie))
|
||||
except http.cookies.CookieError:
|
||||
continue
|
||||
values.update({name: morsel.value for name, morsel in parsed.items()})
|
||||
return values
|
||||
|
||||
|
||||
class NeteaseQrLogin:
|
||||
"""服务端保存二维码 key,登录成功后只把 MUSIC_U 交给保存回调。"""
|
||||
|
||||
_STATUS_MESSAGES = {
|
||||
"idle": "尚未开始扫码登录",
|
||||
"awaiting_scan": "请使用网易云音乐客户端扫码",
|
||||
"awaiting_confirm": "已扫码,请在手机上确认登录",
|
||||
"completed": "登录成功,MUSIC_U 已自动保存",
|
||||
"expired": "二维码已过期,请重新生成",
|
||||
"failed": "网易云扫码登录失败",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_base: Callable[[], str],
|
||||
get_saved_music_u: Callable[[], str],
|
||||
save_music_u: Callable[[str, dict[str, Any]], Any],
|
||||
logger: logging.Logger,
|
||||
account_checker: Callable[[str, str], Any] | None = None,
|
||||
ttl_seconds: int = 180,
|
||||
) -> None:
|
||||
self.api_base = api_base
|
||||
self.get_saved_music_u = get_saved_music_u
|
||||
self.save_music_u = save_music_u
|
||||
self.logger = logger
|
||||
self.account_checker = account_checker
|
||||
self.ttl_seconds = max(60, int(ttl_seconds))
|
||||
self._lock = asyncio.Lock()
|
||||
self._session: dict[str, Any] | None = None
|
||||
|
||||
def _base_url(self) -> str:
|
||||
return str(self.api_base() or "https://music.163.com").rstrip("/")
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
session = self._session or {}
|
||||
state = str(session.get("state") or "idle")
|
||||
expires_at = float(session.get("expires_at") or 0)
|
||||
expires_in = max(0, int(expires_at - time.time())) if expires_at else 0
|
||||
return {
|
||||
"success": True,
|
||||
"state": state,
|
||||
"message": str(session.get("message") or self._STATUS_MESSAGES.get(state, "")),
|
||||
"expires_in": expires_in,
|
||||
"has_qr_image": state in {"awaiting_scan", "awaiting_confirm"} and expires_in > 0,
|
||||
"credential_configured": bool(str(self.get_saved_music_u() or "").strip()),
|
||||
"account": session.get("account"),
|
||||
}
|
||||
|
||||
def _start_sync(self) -> dict[str, Any]:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
payload, _ = _request_weapi_json(
|
||||
f"{self._base_url()}/weapi/login/qrcode/unikey",
|
||||
{"type": 1, "csrf_token": ""},
|
||||
opener=opener,
|
||||
)
|
||||
key = str(payload.get("unikey") or (payload.get("data") or {}).get("unikey") or "").strip()
|
||||
if int(payload.get("code") or 0) != 200 or not key:
|
||||
raise RuntimeError(f"网易云二维码申请失败 code={payload.get('code')}")
|
||||
now = time.time()
|
||||
return {
|
||||
"state": "awaiting_scan",
|
||||
"message": self._STATUS_MESSAGES["awaiting_scan"],
|
||||
"created_at": now,
|
||||
"expires_at": now + self.ttl_seconds,
|
||||
"key": key,
|
||||
# The NetEase client currently accepts the exact HTTP URL emitted by
|
||||
# the official web login page. Using HTTPS shows an unsupported-login warning.
|
||||
"qr_url": f"http://music.163.com/login?codekey={key}",
|
||||
"jar": jar,
|
||||
"opener": opener,
|
||||
"account": None,
|
||||
}
|
||||
|
||||
async def start(self) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
try:
|
||||
self._session = await asyncio.to_thread(self._start_sync)
|
||||
except Exception as exc:
|
||||
self._session = {
|
||||
"state": "failed",
|
||||
"message": str(exc) or type(exc).__name__,
|
||||
"expires_at": 0,
|
||||
}
|
||||
self.logger.warning("[网易云扫码登录] 二维码申请失败: %s", type(exc).__name__)
|
||||
return self.snapshot()
|
||||
self.logger.info("[网易云扫码登录] 二维码已生成,等待扫码")
|
||||
return self.snapshot()
|
||||
|
||||
def _poll_sync(self, session: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self._base_url()}/weapi/login/qrcode/client/login"
|
||||
payload, response = _request_weapi_json(
|
||||
url,
|
||||
{"key": session["key"], "type": 1, "csrf_token": ""},
|
||||
opener=session["opener"],
|
||||
)
|
||||
code = int(payload.get("code") or 0)
|
||||
if code == 801:
|
||||
return {"state": "awaiting_scan", "message": self._STATUS_MESSAGES["awaiting_scan"]}
|
||||
if code == 802:
|
||||
return {"state": "awaiting_confirm", "message": self._STATUS_MESSAGES["awaiting_confirm"]}
|
||||
if code == 800:
|
||||
return {"state": "expired", "message": self._STATUS_MESSAGES["expired"]}
|
||||
if code != 803:
|
||||
return {
|
||||
"state": "failed",
|
||||
"message": str(payload.get("message") or f"网易云扫码状态异常 code={code}"),
|
||||
}
|
||||
music_u = str(_cookie_values(session["jar"], response, payload).get("MUSIC_U") or "").strip()
|
||||
if not music_u:
|
||||
return {"state": "failed", "message": "扫码成功但响应中缺少 MUSIC_U"}
|
||||
return {"state": "authenticated", "music_u": music_u}
|
||||
|
||||
async def _check_account(self, music_u: str) -> dict[str, Any]:
|
||||
if self.account_checker:
|
||||
result = self.account_checker(self._base_url(), music_u)
|
||||
return await result if asyncio.iscoroutine(result) else result
|
||||
resolver = NeteaseResolver(self.logger, api_base=self._base_url(), music_u=music_u)
|
||||
return await resolver.account_status()
|
||||
|
||||
async def poll(self) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
if not self._session:
|
||||
return self.snapshot()
|
||||
state = str(self._session.get("state") or "idle")
|
||||
if state in {"completed", "expired", "failed"}:
|
||||
return self.snapshot()
|
||||
if time.time() >= float(self._session.get("expires_at") or 0):
|
||||
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
||||
return self.snapshot()
|
||||
try:
|
||||
result = await asyncio.to_thread(self._poll_sync, self._session)
|
||||
if result.get("state") != "authenticated":
|
||||
self._session.update(result)
|
||||
return self.snapshot()
|
||||
music_u = str(result.get("music_u") or "")
|
||||
account = await self._check_account(music_u)
|
||||
if not account.get("authenticated"):
|
||||
self._session.update(state="failed", message="扫码 Cookie 登录验证失败")
|
||||
return self.snapshot()
|
||||
callback_result = self.save_music_u(music_u, account)
|
||||
if asyncio.iscoroutine(callback_result):
|
||||
await callback_result
|
||||
self._session.update(
|
||||
state="completed",
|
||||
message=self._STATUS_MESSAGES["completed"],
|
||||
account={
|
||||
"user_id": str(account.get("user_id") or ""),
|
||||
"nickname": str(account.get("nickname") or "网易云用户"),
|
||||
"vip_type": int(account.get("vip_type") or 0),
|
||||
},
|
||||
)
|
||||
self.logger.info("[网易云扫码登录] 登录成功,MUSIC_U 已自动保存")
|
||||
return self.snapshot()
|
||||
except Exception as exc:
|
||||
self._session.update(
|
||||
state="failed",
|
||||
message=str(exc) or f"扫码状态查询异常: {type(exc).__name__}",
|
||||
)
|
||||
self.logger.warning("[网易云扫码登录] 状态查询或保存失败: %s", type(exc).__name__)
|
||||
return self.snapshot()
|
||||
|
||||
async def qr_png(self) -> bytes:
|
||||
async with self._lock:
|
||||
if not self._session or self._session.get("state") not in {"awaiting_scan", "awaiting_confirm"}:
|
||||
raise RuntimeError("当前没有可用的网易云登录二维码")
|
||||
if time.time() >= float(self._session.get("expires_at") or 0):
|
||||
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
||||
raise RuntimeError("网易云登录二维码已过期")
|
||||
content = str(self._session.get("qr_url") or "")
|
||||
return await asyncio.to_thread(_render_qr_png, content)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Resolve NetEase song IDs to short-lived playable audio URLs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
class NeteaseResolver:
|
||||
def __init__(
|
||||
self,
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
api_base: str = "https://music.163.com",
|
||||
music_u: str = "",
|
||||
):
|
||||
self.logger = logger
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.music_u = str(music_u or "").strip()
|
||||
self.last_error_code = ""
|
||||
|
||||
def update_api_base(self, api_base: str):
|
||||
self.api_base = str(api_base or "https://music.163.com").rstrip("/")
|
||||
|
||||
def update_auth(self, music_u: str):
|
||||
self.music_u = str(music_u or "").strip()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
cookie = "os=pc; appver=2.9.8;"
|
||||
if self.music_u:
|
||||
cookie += f" MUSIC_U={self.music_u};"
|
||||
return {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://music.163.com/",
|
||||
"Cookie": cookie,
|
||||
"X-Real-IP": "218.75.111.114",
|
||||
"X-Forwarded-For": "218.75.111.114",
|
||||
}
|
||||
|
||||
async def _probe_url(self, url: str) -> str:
|
||||
timeout = aiohttp.ClientTimeout(total=12)
|
||||
async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session:
|
||||
try:
|
||||
async with session.get(url, allow_redirects=True, headers={"Range": "bytes=0-1"}) as resp:
|
||||
content_type = str(resp.headers.get("Content-Type", "")).lower()
|
||||
if resp.status in (200, 206) and ("audio" in content_type or "octet-stream" in content_type):
|
||||
return str(resp.url)
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
async def _get_json(self, url: str) -> dict[str, Any] | None:
|
||||
timeout = aiohttp.ClientTimeout(total=12)
|
||||
try:
|
||||
async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session:
|
||||
async with session.get(url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
payload = await resp.json(content_type=None)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def account_status(self) -> dict[str, Any]:
|
||||
if not self.music_u:
|
||||
return {"authenticated": False}
|
||||
payload = await self._get_json(f"{self.api_base}/api/nuser/account/get")
|
||||
if not payload:
|
||||
return {"authenticated": False}
|
||||
account = payload.get("account")
|
||||
profile = payload.get("profile")
|
||||
if not isinstance(account, dict) or not account.get("id"):
|
||||
return {"authenticated": False}
|
||||
profile = profile if isinstance(profile, dict) else {}
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user_id": str(account.get("id") or ""),
|
||||
"nickname": str(profile.get("nickname") or "网易云用户"),
|
||||
"vip_type": int(account.get("vipType") or profile.get("vipType") or 0),
|
||||
}
|
||||
|
||||
async def _fetch_player_entry(self, song_id: str) -> dict[str, Any] | None:
|
||||
url = (
|
||||
f"{self.api_base}/api/song/enhance/player/url"
|
||||
f"?ids=%5B{urllib.parse.quote(song_id)}%5D&br=320000"
|
||||
)
|
||||
payload = await self._get_json(url)
|
||||
entries = payload.get("data") if payload else None
|
||||
if not isinstance(entries, list) or not entries or not isinstance(entries[0], dict):
|
||||
return None
|
||||
return entries[0]
|
||||
|
||||
@staticmethod
|
||||
def _is_trial_entry(entry: dict[str, Any]) -> bool:
|
||||
if entry.get("freeTrialInfo"):
|
||||
return True
|
||||
privilege = entry.get("freeTrialPrivilege")
|
||||
if not isinstance(privilege, dict):
|
||||
return False
|
||||
return any(bool(privilege.get(key)) for key in ("resConsumable", "userConsumable", "listenType"))
|
||||
|
||||
@staticmethod
|
||||
def playlist_id(value: str | int) -> str:
|
||||
match = re.search(r"(?:playlist\?id=|\bid=)?(\d{5,})", str(value or ""))
|
||||
return match.group(1) if match else ""
|
||||
|
||||
async def fetch_playlist(self, playlist: str | int) -> dict[str, Any] | None:
|
||||
playlist_id = self.playlist_id(playlist)
|
||||
if not playlist_id:
|
||||
return None
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
url = f"{self.api_base}/api/playlist/detail?id={urllib.parse.quote(playlist_id)}"
|
||||
try:
|
||||
async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session:
|
||||
async with session.get(url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
payload = await resp.json(content_type=None)
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"[mpv] 获取网易云歌单失败: {exc}")
|
||||
return None
|
||||
result = payload.get("result") if isinstance(payload, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
tracks = result.get("tracks")
|
||||
if not isinstance(tracks, list):
|
||||
return None
|
||||
songs: list[dict[str, Any]] = []
|
||||
for track in tracks:
|
||||
if not isinstance(track, dict):
|
||||
continue
|
||||
song_id = self.playlist_id(track.get("id", ""))
|
||||
if not song_id:
|
||||
continue
|
||||
artists_raw = track.get("artists") or track.get("ar") or []
|
||||
artists = "/".join(
|
||||
str(item.get("name") or "").strip()
|
||||
for item in artists_raw
|
||||
if isinstance(item, dict) and str(item.get("name") or "").strip()
|
||||
)
|
||||
album = track.get("album") or track.get("al") or {}
|
||||
duration_ms = track.get("duration") or track.get("dt") or 0
|
||||
try:
|
||||
duration_sec = max(0, int(duration_ms) // 1000)
|
||||
except (TypeError, ValueError):
|
||||
duration_sec = 0
|
||||
songs.append({
|
||||
"id": song_id,
|
||||
"name": str(track.get("name") or f"歌曲{song_id}"),
|
||||
"artist": artists or "未知歌手",
|
||||
"duration_sec": duration_sec,
|
||||
"cover": str(album.get("picUrl") or "") if isinstance(album, dict) else "",
|
||||
"source": "background_playlist",
|
||||
"playlist_id": playlist_id,
|
||||
})
|
||||
if not songs:
|
||||
return None
|
||||
return {
|
||||
"id": playlist_id,
|
||||
"name": str(result.get("name") or f"歌单{playlist_id}"),
|
||||
"songs": songs,
|
||||
}
|
||||
|
||||
async def fetch_song_detail(self, song_id: str | int) -> dict[str, Any] | None:
|
||||
clean_id = self.playlist_id(song_id)
|
||||
if not clean_id:
|
||||
return None
|
||||
timeout = aiohttp.ClientTimeout(total=12)
|
||||
url = f"{self.api_base}/api/song/detail/?id={clean_id}&ids=[{clean_id}]"
|
||||
try:
|
||||
async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session:
|
||||
async with session.get(url, allow_redirects=True) as resp:
|
||||
payload = await resp.json(content_type=None)
|
||||
except Exception:
|
||||
return None
|
||||
songs = payload.get("songs") if isinstance(payload, dict) else None
|
||||
if not isinstance(songs, list) or not songs or not isinstance(songs[0], dict):
|
||||
return None
|
||||
track = songs[0]
|
||||
album = track.get("album") or track.get("al") or {}
|
||||
return {
|
||||
"cover": str(album.get("picUrl") or album.get("blurPicUrl") or "") if isinstance(album, dict) else "",
|
||||
}
|
||||
|
||||
async def resolve(self, song: dict[str, Any]) -> dict[str, Any] | None:
|
||||
self.last_error_code = ""
|
||||
song_id = "".join(ch for ch in str(song.get("id", "")) if ch.isdigit())
|
||||
if not song_id:
|
||||
self.last_error_code = "invalid_song_id"
|
||||
return None
|
||||
entry = await self._fetch_player_entry(song_id)
|
||||
if entry:
|
||||
if self._is_trial_entry(entry):
|
||||
reason = "登录已失效或账号没有完整播放权益" if self.music_u else "未配置网易云登录"
|
||||
self.logger.warning(
|
||||
f"[mpv] 拒绝播放试听片段: {song.get('name')} ({song_id}),{reason}"
|
||||
)
|
||||
self.last_error_code = "preview_only"
|
||||
return None
|
||||
player_url = str(entry.get("url") or "").strip()
|
||||
if player_url:
|
||||
resolved = await self._probe_url(player_url)
|
||||
if resolved:
|
||||
return {
|
||||
"url": resolved,
|
||||
"source": "netease.player.auth" if self.music_u else "netease.player",
|
||||
"song_id": song_id,
|
||||
}
|
||||
# NetEase's public outer URL provides a short-lived CDN redirect for songs available to the current region/account tier.
|
||||
outer = f"{self.api_base}/song/media/outer/url?id={urllib.parse.quote(song_id)}.mp3"
|
||||
resolved = await self._probe_url(outer)
|
||||
if not resolved:
|
||||
self.last_error_code = "unavailable"
|
||||
self.logger.warning(f"[mpv] 无法获取可播放地址: {song.get('name')} ({song_id})")
|
||||
return None
|
||||
return {
|
||||
"url": resolved,
|
||||
"source": "netease.outer",
|
||||
"song_id": song_id,
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"""兑换码的 SQLite 存储与原子领取逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BEIJING_TZ = timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||
|
||||
|
||||
def normalize_code(value: str) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_beijing_datetime(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("生效时间和失效时间不能为空")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("时间格式无效") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=BEIJING_TZ)
|
||||
return parsed.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def to_beijing_datetime(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(BEIJING_TZ).isoformat(timespec="minutes")
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS redemption_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code_normalized TEXT NOT NULL UNIQUE,
|
||||
code_display TEXT NOT NULL,
|
||||
points INTEGER NOT NULL CHECK(points > 0),
|
||||
starts_at_utc TEXT NOT NULL,
|
||||
ends_at_utc TEXT NOT NULL,
|
||||
max_redemptions INTEGER CHECK(max_redemptions IS NULL OR max_redemptions > 0),
|
||||
redeemed_count INTEGER NOT NULL DEFAULT 0 CHECK(redeemed_count >= 0),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
|
||||
deleted_at_utc TEXT,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL,
|
||||
CHECK(starts_at_utc < ends_at_utc)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS redemption_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code_id INTEGER NOT NULL REFERENCES redemption_codes(id),
|
||||
code_display TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
platform_user_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
points INTEGER NOT NULL,
|
||||
balance_before INTEGER NOT NULL,
|
||||
balance_after INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
redeemed_at_utc TEXT NOT NULL,
|
||||
completed_at_utc TEXT,
|
||||
UNIQUE(code_id, platform, platform_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_redemption_codes_active
|
||||
ON redemption_codes(deleted_at_utc, enabled, starts_at_utc, ends_at_utc);
|
||||
CREATE INDEX IF NOT EXISTS idx_redemption_records_code_time
|
||||
ON redemption_records(code_id, redeemed_at_utc DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_redemption_records_user_time
|
||||
ON redemption_records(platform, platform_user_id, redeemed_at_utc DESC);
|
||||
"""
|
||||
|
||||
|
||||
class RedemptionCodeStore:
|
||||
"""用短事务处理兑换码,避免并发超领和重复领取。
|
||||
|
||||
当提供 stats_store 时,所有读写都复用 stats_store 的唯一连接与串行写队列,
|
||||
消除多连接写同一文件导致的 "database is locked" 争用。
|
||||
"""
|
||||
|
||||
def __init__(self, database_path: str | Path, stats_store: Any | None = None):
|
||||
self.database_path = Path(database_path)
|
||||
self._store = stats_store
|
||||
self._lock = threading.RLock()
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(_SCHEMA)
|
||||
connection.commit()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.database_path, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=NORMAL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _run(self, func, *args):
|
||||
"""在 stats_store 单连接(或自有连接)上执行 func(connection, *args)。"""
|
||||
if self._store is not None:
|
||||
return self._store.execute(func, *args)
|
||||
return asyncio.to_thread(self._run_direct, func, args)
|
||||
|
||||
def _run_direct(self, func, args):
|
||||
with self._lock, self._connect() as connection:
|
||||
return func(connection, *args)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_code(row: sqlite3.Row) -> dict[str, Any]:
|
||||
max_redemptions = row["max_redemptions"]
|
||||
redeemed_count = int(row["redeemed_count"] or 0)
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"code": row["code_display"],
|
||||
"points": int(row["points"]),
|
||||
"starts_at": to_beijing_datetime(row["starts_at_utc"]),
|
||||
"ends_at": to_beijing_datetime(row["ends_at_utc"]),
|
||||
"max_redemptions": int(max_redemptions) if max_redemptions is not None else None,
|
||||
"redeemed_count": redeemed_count,
|
||||
"remaining_count": max(0, int(max_redemptions) - redeemed_count) if max_redemptions is not None else None,
|
||||
"enabled": bool(row["enabled"]),
|
||||
"created_at": to_beijing_datetime(row["created_at_utc"]),
|
||||
"updated_at": to_beijing_datetime(row["updated_at_utc"]),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_record(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"code_id": int(row["code_id"]),
|
||||
"code": row["code_display"],
|
||||
"platform": row["platform"],
|
||||
"uid": row["platform_user_id"],
|
||||
"uname": row["display_name"],
|
||||
"points": int(row["points"]),
|
||||
"balance_before": int(row["balance_before"]),
|
||||
"balance_after": int(row["balance_after"]) if row["balance_after"] is not None else None,
|
||||
"status": row["status"],
|
||||
"redeemed_at": to_beijing_datetime(row["redeemed_at_utc"]),
|
||||
"completed_at": to_beijing_datetime(row["completed_at_utc"]),
|
||||
}
|
||||
|
||||
async def create_code(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
points: int,
|
||||
starts_at: str,
|
||||
ends_at: str,
|
||||
max_redemptions: int | None,
|
||||
enabled: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return await self._run(
|
||||
self._create_code,
|
||||
code,
|
||||
points,
|
||||
starts_at,
|
||||
ends_at,
|
||||
max_redemptions,
|
||||
enabled,
|
||||
)
|
||||
|
||||
def _create_code(
|
||||
self,
|
||||
connection,
|
||||
code: str,
|
||||
points: int,
|
||||
starts_at: str,
|
||||
ends_at: str,
|
||||
max_redemptions: int | None,
|
||||
enabled: bool,
|
||||
) -> dict[str, Any]:
|
||||
display = str(code or "").strip()
|
||||
normalized = normalize_code(display)
|
||||
if not normalized:
|
||||
raise ValueError("兑换码不能为空")
|
||||
if len(display) > 100:
|
||||
raise ValueError("兑换码不能超过100个字符")
|
||||
points = int(points)
|
||||
if points <= 0:
|
||||
raise ValueError("兑换积分必须是正整数")
|
||||
if max_redemptions in ("", None):
|
||||
max_value = None
|
||||
else:
|
||||
max_value = int(max_redemptions)
|
||||
if max_value <= 0:
|
||||
raise ValueError("总兑换次数必须是正整数,或留空表示不限")
|
||||
starts_utc = parse_beijing_datetime(starts_at)
|
||||
ends_utc = parse_beijing_datetime(ends_at)
|
||||
if starts_utc >= ends_utc:
|
||||
raise ValueError("失效时间必须晚于生效时间")
|
||||
now = utc_now()
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""INSERT INTO redemption_codes (
|
||||
code_normalized, code_display, points, starts_at_utc, ends_at_utc,
|
||||
max_redemptions, enabled, created_at_utc, updated_at_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(normalized, display, points, starts_utc, ends_utc, max_value, int(bool(enabled)), now, now),
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
connection.rollback()
|
||||
if "code_normalized" in str(exc) or "UNIQUE constraint" in str(exc):
|
||||
raise ValueError("兑换码已存在") from exc
|
||||
raise
|
||||
row = connection.execute("SELECT * FROM redemption_codes WHERE id=?", (cursor.lastrowid,)).fetchone()
|
||||
assert row is not None
|
||||
return self._serialize_code(row)
|
||||
|
||||
async def list_codes(self) -> list[dict[str, Any]]:
|
||||
return await self._run(self._list_codes)
|
||||
|
||||
def _list_codes(self, connection) -> list[dict[str, Any]]:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM redemption_codes WHERE deleted_at_utc IS NULL ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [self._serialize_code(row) for row in rows]
|
||||
|
||||
async def list_records(self, *, code_id: int | None = None, limit: int = 500) -> list[dict[str, Any]]:
|
||||
return await self._run(self._list_records, code_id, limit)
|
||||
|
||||
def _list_records(self, connection, code_id: int | None, limit: int) -> list[dict[str, Any]]:
|
||||
limit = max(1, min(2000, int(limit)))
|
||||
if code_id is None:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM redemption_records ORDER BY id DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM redemption_records WHERE code_id=? ORDER BY id DESC LIMIT ?",
|
||||
(int(code_id), limit),
|
||||
).fetchall()
|
||||
return [self._serialize_record(row) for row in rows]
|
||||
|
||||
async def set_enabled(self, code_id: int, enabled: bool) -> dict[str, Any]:
|
||||
return await self._run(self._set_enabled, code_id, enabled)
|
||||
|
||||
def _set_enabled(self, connection, code_id: int, enabled: bool) -> dict[str, Any]:
|
||||
cursor = connection.execute(
|
||||
"UPDATE redemption_codes SET enabled=?, updated_at_utc=? WHERE id=? AND deleted_at_utc IS NULL",
|
||||
(int(bool(enabled)), utc_now(), int(code_id)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
connection.rollback()
|
||||
raise ValueError("兑换码不存在")
|
||||
connection.commit()
|
||||
row = connection.execute("SELECT * FROM redemption_codes WHERE id=?", (int(code_id),)).fetchone()
|
||||
assert row is not None
|
||||
return self._serialize_code(row)
|
||||
|
||||
async def delete_code(self, code_id: int) -> None:
|
||||
await self._run(self._delete_code, code_id)
|
||||
|
||||
def _delete_code(self, connection, code_id: int) -> None:
|
||||
now = utc_now()
|
||||
cursor = connection.execute(
|
||||
"""UPDATE redemption_codes
|
||||
SET enabled=0, deleted_at_utc=?, updated_at_utc=?
|
||||
WHERE id=? AND deleted_at_utc IS NULL""",
|
||||
(now, now, int(code_id)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
connection.rollback()
|
||||
raise ValueError("兑换码不存在")
|
||||
connection.commit()
|
||||
|
||||
async def reserve(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
platform: str,
|
||||
platform_user_id: str,
|
||||
display_name: str,
|
||||
balance_before: int,
|
||||
) -> dict[str, Any]:
|
||||
return await self._run(
|
||||
self._reserve,
|
||||
message,
|
||||
platform,
|
||||
platform_user_id,
|
||||
display_name,
|
||||
balance_before,
|
||||
)
|
||||
|
||||
def _reserve(
|
||||
self,
|
||||
connection,
|
||||
message: str,
|
||||
platform: str,
|
||||
platform_user_id: str,
|
||||
display_name: str,
|
||||
balance_before: int,
|
||||
) -> dict[str, Any]:
|
||||
normalized = normalize_code(message)
|
||||
if not normalized:
|
||||
return {"status": "unknown"}
|
||||
now = utc_now()
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
code = connection.execute(
|
||||
"SELECT * FROM redemption_codes WHERE code_normalized=? AND deleted_at_utc IS NULL",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
if code is None:
|
||||
connection.rollback()
|
||||
return {"status": "unknown"}
|
||||
if not bool(code["enabled"]):
|
||||
connection.rollback()
|
||||
return {"status": "disabled"}
|
||||
if now < code["starts_at_utc"]:
|
||||
connection.rollback()
|
||||
return {"status": "not_started"}
|
||||
if now >= code["ends_at_utc"]:
|
||||
connection.rollback()
|
||||
return {"status": "expired"}
|
||||
existing = connection.execute(
|
||||
"""SELECT status FROM redemption_records
|
||||
WHERE code_id=? AND platform=? AND platform_user_id=?""",
|
||||
(code["id"], platform, platform_user_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
return {"status": "already_redeemed"}
|
||||
max_redemptions = code["max_redemptions"]
|
||||
if max_redemptions is not None and int(code["redeemed_count"]) >= int(max_redemptions):
|
||||
connection.rollback()
|
||||
return {"status": "exhausted"}
|
||||
cursor = connection.execute(
|
||||
"""INSERT INTO redemption_records (
|
||||
code_id, code_display, platform, platform_user_id, display_name,
|
||||
points, balance_before, status, redeemed_at_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)""",
|
||||
(
|
||||
code["id"], code["code_display"], platform, platform_user_id,
|
||||
display_name, code["points"], int(balance_before), now,
|
||||
),
|
||||
)
|
||||
updated = connection.execute(
|
||||
"""UPDATE redemption_codes
|
||||
SET redeemed_count=redeemed_count+1, updated_at_utc=?
|
||||
WHERE id=? AND (max_redemptions IS NULL OR redeemed_count < max_redemptions)""",
|
||||
(now, code["id"]),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
connection.rollback()
|
||||
return {"status": "exhausted"}
|
||||
connection.commit()
|
||||
return {
|
||||
"status": "reserved",
|
||||
"record_id": int(cursor.lastrowid),
|
||||
"code_id": int(code["id"]),
|
||||
"code": code["code_display"],
|
||||
"points": int(code["points"]),
|
||||
}
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
return {"status": "already_redeemed"}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
async def finalize(self, record_id: int, balance_after: int) -> None:
|
||||
await self._run(self._finalize, record_id, balance_after)
|
||||
|
||||
def _finalize(self, connection, record_id: int, balance_after: int) -> None:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE redemption_records
|
||||
SET status='completed', balance_after=?, completed_at_utc=?
|
||||
WHERE id=? AND status='pending'""",
|
||||
(int(balance_after), utc_now(), int(record_id)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
connection.rollback()
|
||||
raise ValueError("兑换记录不存在或已经完成")
|
||||
connection.commit()
|
||||
|
||||
async def cancel(self, record_id: int) -> None:
|
||||
await self._run(self._cancel, record_id)
|
||||
|
||||
def _cancel(self, connection, record_id: int) -> None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT code_id FROM redemption_records WHERE id=? AND status='pending'",
|
||||
(int(record_id),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
return
|
||||
connection.execute("DELETE FROM redemption_records WHERE id=?", (int(record_id),))
|
||||
connection.execute(
|
||||
"""UPDATE redemption_codes
|
||||
SET redeemed_count=MAX(0, redeemed_count-1), updated_at_utc=?
|
||||
WHERE id=?""",
|
||||
(utc_now(), int(row["code_id"])),
|
||||
)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
+1733
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
"""TTS 状态监控窗口
|
||||
|
||||
独立小黑窗显示 TTS 引擎状态、最近合成文本和耗时。
|
||||
启动参数:
|
||||
tts_monitor.py --state-file data/tts_state.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
from core.runtime_paths import DATA_DIR, ensure_runtime_dirs
|
||||
|
||||
|
||||
def clear():
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def default_state() -> dict:
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def load_state(state_file: Path) -> dict:
|
||||
if not state_file.exists():
|
||||
return default_state()
|
||||
try:
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
state = default_state()
|
||||
state.update(data)
|
||||
return state
|
||||
except Exception:
|
||||
pass
|
||||
return default_state()
|
||||
|
||||
|
||||
def fmt_duration(ms: int) -> str:
|
||||
if ms <= 0:
|
||||
return "-"
|
||||
if ms < 1000:
|
||||
return f"{ms}ms"
|
||||
return f"{ms/1000:.2f}s"
|
||||
|
||||
|
||||
def render(state: dict):
|
||||
clear()
|
||||
print("=" * 60)
|
||||
print(" TTS 状态监控")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
enabled = state.get("enabled", False)
|
||||
provider = state.get("provider", "none")
|
||||
model_loaded = state.get("model_loaded", False)
|
||||
|
||||
print(f" TTS 启用: {'是' if enabled else '否'}")
|
||||
print(f" 引擎: {provider}")
|
||||
print(f" 模型加载: {'完成' if model_loaded else '未加载/加载中'}")
|
||||
print(f" 模型名: {state.get('model_name', '') or '-'}")
|
||||
print()
|
||||
print(f" 累计合成: {state.get('total_synthesized', 0)} 次")
|
||||
print(f" 累计失败: {state.get('total_errors', 0)} 次")
|
||||
print(f" 上次合成: {state.get('last_text', '') or '-'}")
|
||||
print(f" 合成耗时: {fmt_duration(state.get('last_duration_ms', 0))}")
|
||||
print()
|
||||
|
||||
last_error = state.get("last_error", "")
|
||||
if last_error:
|
||||
print(f" [错误] {last_error}")
|
||||
print()
|
||||
|
||||
print("-" * 60)
|
||||
print(" 最近事件")
|
||||
print("-" * 60)
|
||||
events = state.get("recent_events", [])
|
||||
if not events:
|
||||
print(" (无)")
|
||||
else:
|
||||
for ev in events[-8:]:
|
||||
ts = ev.get("time", "")
|
||||
msg = ev.get("msg", "")
|
||||
print(f" {ts} {msg}")
|
||||
print()
|
||||
print(" 按 Ctrl+C 关闭本窗口")
|
||||
|
||||
|
||||
def main():
|
||||
ensure_runtime_dirs()
|
||||
parser = argparse.ArgumentParser(description="TTS 状态监控")
|
||||
parser.add_argument("--state-file", default=str(DATA_DIR / "tts_state.json"), help="TTS 状态文件路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
state_file = Path(args.state_file)
|
||||
print("等待 TTS 状态更新...")
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
while True:
|
||||
state = load_state(state_file)
|
||||
render(state)
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("\nTTS 监控已关闭")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user