Files
archived-MoviePilot/app/helper/server.py

1746 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import platform
from pathlib import Path
from threading import Thread
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, quote, urlparse, urlsplit
from app.core.cache import cached
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.workflow_oper import WorkflowOper
from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey, media_type_to_agent
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.system import SystemUtils
from version import APP_VERSION, FRONTEND_VERSION
class MoviePilotServerHelper:
"""
MoviePilot 服务端请求辅助工具。
"""
USER_UID_HEADER = "X-MoviePilot-User-Uid"
_USAGE_REPORT_PATH = "/usage/report"
_USAGE_STATISTIC_PATH = "/usage/statistic"
_PLUGIN_INSTALL_PATH = "/plugin/install"
_PLUGIN_STATISTIC_PATH = "/plugin/statistic"
_SUBSCRIBE_ADD_PATH = "/subscribe/add"
_SUBSCRIBE_DONE_PATH = "/subscribe/done"
_SUBSCRIBE_REPORT_PATH = "/subscribe/report"
_SUBSCRIBE_STATISTIC_PATH = "/subscribe/statistic"
_SUBSCRIBE_SHARE_PATH = "/subscribe/share"
_SUBSCRIBE_SHARES_PATH = "/subscribe/shares"
_SUBSCRIBE_SHARE_STATISTICS_PATH = "/subscribe/share/statistics"
_SUBSCRIBE_FORK_PATH = "/subscribe/fork"
_WORKFLOW_SHARE_PATH = "/workflow/share"
_WORKFLOW_SHARES_PATH = "/workflow/shares"
_WORKFLOW_FORK_PATH = "/workflow/fork"
_RECOGNIZE_SHARE_PATH = "/recognize/share"
_USER_PERMISSIONS_PATH = "/user/permissions"
_LOCAL_REPO_PREFIX = "local://"
_user_uid: Optional[str] = None
_github_user: Optional[str] = None
@classmethod
def get_user_uid(cls) -> Optional[str]:
"""
获取当前安装实例用于服务端统计识别的稳定用户 ID。
"""
if cls._user_uid is None:
cls._user_uid = SystemUtils.generate_user_unique_id()
return cls._user_uid
@staticmethod
def is_server_url(url: str) -> bool:
"""
判断请求地址是否指向配置中的 MoviePilot 服务端。
"""
server_host = (settings.MP_SERVER_HOST or "").strip().rstrip("/")
if not server_host or not url:
return False
try:
target = urlparse(str(url).strip())
server = urlparse(server_host)
except Exception:
return False
return bool(
target.scheme
and target.netloc
and server.scheme
and server.netloc
and target.scheme == server.scheme
and target.netloc == server.netloc
)
@classmethod
def build_headers(
cls,
url: str,
headers: Optional[dict] = None,
content_type: Optional[str] = None,
) -> dict:
"""
构建访问 MoviePilot 服务端需要的请求头。
"""
request_headers = {
key: value
for key, value in (headers or {}).items()
if value is not None
}
if content_type and not cls._has_header(request_headers, "Content-Type"):
request_headers["Content-Type"] = content_type
if not cls.is_server_url(url) or cls._has_header(request_headers, cls.USER_UID_HEADER):
return request_headers
user_uid = cls.get_user_uid()
if user_uid:
request_headers[cls.USER_UID_HEADER] = user_uid
request_headers["User-Agent"] = settings.USER_AGENT
return request_headers
@classmethod
def get_user_uuid(cls) -> str:
"""
获取当前用户 UUID。
"""
user_uid = cls.get_user_uid()
if user_uid:
logger.info(f"当前用户UUID: {user_uid}")
return user_uid or ""
@classmethod
def get_github_user(cls) -> str:
"""
获取当前 GitHub 用户名。
"""
if cls._github_user is None and settings.GITHUB_HEADERS:
res = RequestUtils(
headers=settings.GITHUB_HEADERS,
proxies=settings.PROXY,
timeout=15,
).get_res("https://api.github.com/user")
if res:
cls._github_user = res.json().get("login")
logger.info(f"当前Github用户: {cls._github_user}")
return cls._github_user or ""
@classmethod
async def async_get_github_user(cls) -> str:
"""
异步获取当前 GitHub 用户名。
"""
if cls._github_user is None and settings.GITHUB_HEADERS:
res = await AsyncRequestUtils(
headers=settings.GITHUB_HEADERS,
proxies=settings.PROXY,
timeout=15,
).get_res("https://api.github.com/user")
if res:
cls._github_user = res.json().get("login")
logger.info(f"当前Github用户: {cls._github_user}")
return cls._github_user or ""
@classmethod
def user_permissions(cls, github_user: str):
"""
查询服务端用户权限。
"""
return cls._get(
cls._server_url(cls._USER_PERMISSIONS_PATH),
params={"github_user": github_user},
include_user_uid=False,
timeout=5,
)
@classmethod
async def async_user_permissions(cls, github_user: str):
"""
异步查询服务端用户权限。
"""
return await cls._async_get(
cls._server_url(cls._USER_PERMISSIONS_PATH),
params={"github_user": github_user},
include_user_uid=False,
timeout=5,
)
@classmethod
def get_user_permissions(cls) -> Dict[str, Any]:
"""
获取当前用户在服务端配置中的权限。
"""
github_user = cls.get_github_user()
if not github_user:
return {}
try:
res = cls.user_permissions(github_user)
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"获取服务端用户权限失败:{str(err)}")
return {}
@classmethod
async def async_get_user_permissions(cls) -> Dict[str, Any]:
"""
异步获取当前用户在服务端配置中的权限。
"""
github_user = await cls.async_get_github_user()
if not github_user:
return {}
try:
res = await cls.async_user_permissions(github_user)
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"异步获取服务端用户权限失败:{str(err)}")
return {}
@classmethod
def is_admin_user(cls) -> bool:
"""
判断当前用户是否为共享管理用户。
"""
permissions = cls.get_user_permissions()
return bool(
permissions.get("is_admin")
or permissions.get("subscribe_share_manage")
or permissions.get("workflow_share_manage")
)
@classmethod
async def async_is_admin_user(cls) -> bool:
"""
异步判断当前用户是否为共享管理用户。
"""
permissions = await cls.async_get_user_permissions()
return bool(
permissions.get("is_admin")
or permissions.get("subscribe_share_manage")
or permissions.get("workflow_share_manage")
)
@staticmethod
def get_frontend_version() -> str:
"""
获取当前前端版本。
"""
if SystemUtils.is_frozen() and SystemUtils.is_windows():
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
else:
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, "r") as file:
version = str(file.read()).strip()
return version or FRONTEND_VERSION
except Exception as err:
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
return FRONTEND_VERSION
@classmethod
def build_usage_payload(cls) -> Dict[str, Any]:
"""
构建安装版本统计上报载荷。
"""
return {
"user_uid": cls.get_user_uid(),
"backend_version": APP_VERSION,
"frontend_version": cls.get_frontend_version(),
"version_flag": settings.VERSION_FLAG,
"platform": f"{platform.system()} {platform.release()}".strip(),
"arch": SystemUtils.cpu_arch(),
}
@classmethod
def report_usage(cls) -> bool:
"""
上报当前安装实例的版本统计。
"""
if not settings.USAGE_STATISTIC_SHARE:
return False
payload = cls.build_usage_payload()
if not payload.get("user_uid"):
return False
try:
res = cls.usage_report(payload)
return bool(res is not None and res.status_code == 200)
except Exception as err:
logger.debug(f"上报安装版本统计失败:{str(err)}")
return False
@classmethod
async def async_report_usage(cls) -> bool:
"""
异步上报当前安装实例的版本统计。
"""
if not settings.USAGE_STATISTIC_SHARE:
return False
payload = cls.build_usage_payload()
if not payload.get("user_uid"):
return False
try:
res = await cls.async_usage_report(payload)
return bool(res is not None and res.status_code == 200)
except Exception as err:
logger.debug(f"异步上报安装版本统计失败:{str(err)}")
return False
@classmethod
async def async_get_usage_statistic(cls) -> Dict[str, Any]:
"""
异步获取安装版本统计报表。
"""
if not settings.USAGE_STATISTIC_SHARE:
return {}
try:
res = await cls.async_usage_statistic()
if res is not None and res.status_code == 200:
return res.json()
except Exception as err:
logger.debug(f"异步获取安装版本统计报表失败:{str(err)}")
return {}
@classmethod
def init_subscribe_report(cls) -> None:
"""
初始化订阅统计上报状态。
"""
systemconfig = SystemConfigOper()
if settings.SUBSCRIBE_STATISTIC_SHARE:
if not systemconfig.get(SystemConfigKey.SubscribeReport):
if cls.sub_report():
systemconfig.set(SystemConfigKey.SubscribeReport, "1")
@classmethod
def init_plugin_report(cls) -> None:
"""
初始化插件安装统计上报状态。
"""
systemconfig = SystemConfigOper()
if settings.PLUGIN_STATISTIC_SHARE:
if not systemconfig.get(SystemConfigKey.PluginInstallReport):
if cls.install_plugin_report():
systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
@staticmethod
def _handle_list_response(res) -> List[dict]:
"""
处理服务端返回的列表响应。
"""
if res is not None and res.status_code == 200:
return res.json()
return []
@staticmethod
def _handle_response(res, clear_cache=None) -> Tuple[bool, str]:
"""
处理服务端写入类接口响应。
"""
if res is None:
return False, "连接MoviePilot服务器失败"
if res.status_code == 200:
if clear_cache:
clear_cache()
return True, ""
try:
return False, res.json().get("message", "未知错误")
except (json.JSONDecodeError, ValueError, AttributeError):
return False, f"响应解析失败: {getattr(res, 'text', '')[:100]}..."
@classmethod
def usage_report(cls, payload: Dict[str, Any]):
"""
上报安装版本统计。
"""
return cls._post_json(cls._server_url(cls._USAGE_REPORT_PATH), payload, timeout=5)
@classmethod
async def async_usage_report(cls, payload: Dict[str, Any]):
"""
异步上报安装版本统计。
"""
return await cls._async_post_json(cls._server_url(cls._USAGE_REPORT_PATH), payload, timeout=5)
@classmethod
def usage_statistic(cls):
"""
获取安装版本统计报表。
"""
return cls._get(cls._server_url(cls._USAGE_STATISTIC_PATH), timeout=10)
@classmethod
async def async_usage_statistic(cls):
"""
异步获取安装版本统计报表。
"""
return await cls._async_get(cls._server_url(cls._USAGE_STATISTIC_PATH), timeout=10)
@classmethod
def plugin_statistic(cls):
"""
获取插件安装统计。
"""
return cls._get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10)
@classmethod
async def async_plugin_statistic(cls):
"""
异步获取插件安装统计。
"""
return await cls._async_get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10)
@classmethod
def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]):
"""
上报单个插件安装统计。
"""
return cls._post_json(f"{cls._server_url(cls._PLUGIN_INSTALL_PATH)}/{plugin_id}", payload, timeout=5)
@classmethod
async def async_plugin_install(cls, plugin_id: str, payload: Dict[str, Any]):
"""
异步上报单个插件安装统计。
"""
return await cls._async_post_json(
f"{cls._server_url(cls._PLUGIN_INSTALL_PATH)}/{plugin_id}",
payload,
timeout=5,
)
@classmethod
def plugin_install_report(cls, plugins: List[Dict[str, Any]]):
"""
批量上报插件安装统计。
"""
return cls._post_json(cls._server_url(cls._PLUGIN_INSTALL_PATH), {"plugins": plugins}, timeout=5)
@classmethod
async def async_plugin_install_report(cls, plugins: List[Dict[str, Any]]):
"""
异步批量上报插件安装统计。
"""
return await cls._async_post_json(
cls._server_url(cls._PLUGIN_INSTALL_PATH),
{"plugins": plugins},
timeout=5,
)
@classmethod
@cached(maxsize=1, ttl=1800)
def get_plugin_statistic(cls) -> Dict:
"""
获取插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return {}
res = cls.plugin_statistic()
if res is not None and res.status_code == 200:
return res.json()
return {}
@classmethod
async def async_get_plugin_statistic(cls) -> Dict:
"""
异步获取插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return {}
res = await cls.async_plugin_statistic()
if res is not None and res.status_code == 200:
return res.json()
return {}
@classmethod
def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
"""
上报单个插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
if not plugin_id:
return False
res = cls.plugin_install(plugin_id, {
"plugin_id": plugin_id,
"repo_url": cls.sanitize_plugin_repo_url(repo_url),
})
return bool(res is not None and res.status_code == 200)
@classmethod
async def async_install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
"""
异步上报单个插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
if not plugin_id:
return False
res = await cls.async_plugin_install(plugin_id, {
"plugin_id": plugin_id,
"repo_url": cls.sanitize_plugin_repo_url(repo_url),
})
return bool(res is not None and res.status_code == 200)
@classmethod
def install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
"""
批量上报存量插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
payload_plugins = cls._build_plugin_report_payload(items)
if not payload_plugins:
return False
res = cls.plugin_install_report(payload_plugins)
return bool(res is not None and res.status_code == 200)
@classmethod
async def async_install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
"""
异步批量上报存量插件安装统计。
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
payload_plugins = cls._build_plugin_report_payload(items)
if not payload_plugins:
return False
res = await cls.async_plugin_install_report(payload_plugins)
return bool(res is not None and res.status_code == 200)
@classmethod
def subscribe_statistic(cls, params: Dict[str, Any]):
"""
获取订阅统计数据。
"""
return cls._get(cls._server_url(cls._SUBSCRIBE_STATISTIC_PATH), params=params, timeout=15)
@classmethod
async def async_subscribe_statistic(cls, params: Dict[str, Any]):
"""
异步获取订阅统计数据。
"""
return await cls._async_get(cls._server_url(cls._SUBSCRIBE_STATISTIC_PATH), params=params, timeout=15)
@classmethod
def subscribe_add(cls, payload: Dict[str, Any]):
"""
新增订阅统计。
"""
return cls._post_json(cls._server_url(cls._SUBSCRIBE_ADD_PATH), payload, timeout=5)
@classmethod
async def async_subscribe_add(cls, payload: Dict[str, Any]):
"""
异步新增订阅统计。
"""
return await cls._async_post_json(cls._server_url(cls._SUBSCRIBE_ADD_PATH), payload, timeout=5)
@classmethod
def subscribe_done(cls, payload: Dict[str, Any]):
"""
完成订阅统计。
"""
return cls._post_json(cls._server_url(cls._SUBSCRIBE_DONE_PATH), payload, timeout=5)
@classmethod
def subscribe_report(cls, subscribes: List[Dict[str, Any]]):
"""
批量上报存量订阅统计。
"""
return cls._post_json(
cls._server_url(cls._SUBSCRIBE_REPORT_PATH),
{"subscribes": subscribes},
timeout=10,
)
@classmethod
def subscribe_share(cls, payload: Dict[str, Any]):
"""
分享订阅数据。
"""
return cls._post_json(cls._server_url(cls._SUBSCRIBE_SHARE_PATH), payload, timeout=10)
@classmethod
async def async_subscribe_share(cls, payload: Dict[str, Any]):
"""
异步分享订阅数据。
"""
return await cls._async_post_json(cls._server_url(cls._SUBSCRIBE_SHARE_PATH), payload, timeout=10)
@classmethod
def subscribe_share_delete(cls, share_id: int, share_uid: str):
"""
删除订阅分享数据。
"""
return cls._delete(
f"{cls._server_url(cls._SUBSCRIBE_SHARE_PATH)}/{share_id}",
params={"share_uid": share_uid},
timeout=5,
)
@classmethod
async def async_subscribe_share_delete(cls, share_id: int, share_uid: str):
"""
异步删除订阅分享数据。
"""
return await cls._async_delete(
f"{cls._server_url(cls._SUBSCRIBE_SHARE_PATH)}/{share_id}",
params={"share_uid": share_uid},
timeout=5,
)
@classmethod
def subscribe_fork(cls, share_id: int):
"""
复用订阅分享数据。
"""
return cls._get(f"{cls._server_url(cls._SUBSCRIBE_FORK_PATH)}/{share_id}", timeout=5)
@classmethod
async def async_subscribe_fork(cls, share_id: int):
"""
异步复用订阅分享数据。
"""
return await cls._async_get(f"{cls._server_url(cls._SUBSCRIBE_FORK_PATH)}/{share_id}", timeout=5)
@classmethod
def subscribe_shares(cls, params: Dict[str, Any]):
"""
获取订阅分享数据。
"""
return cls._get(cls._server_url(cls._SUBSCRIBE_SHARES_PATH), params=params, timeout=15)
@classmethod
async def async_subscribe_shares(cls, params: Dict[str, Any]):
"""
异步获取订阅分享数据。
"""
return await cls._async_get(cls._server_url(cls._SUBSCRIBE_SHARES_PATH), params=params, timeout=15)
@classmethod
def subscribe_share_statistics(cls):
"""
获取订阅分享统计数据。
"""
return cls._get(cls._server_url(cls._SUBSCRIBE_SHARE_STATISTICS_PATH), timeout=15)
@classmethod
async def async_subscribe_share_statistics(cls):
"""
异步获取订阅分享统计数据。
"""
return await cls._async_get(cls._server_url(cls._SUBSCRIBE_SHARE_STATISTICS_PATH), timeout=15)
@staticmethod
def _build_subscribe_query_params(
page: Optional[int] = 1,
count: Optional[int] = 30,
genre_id: Optional[int] = None,
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
**extra,
) -> Dict[str, Any]:
"""
构建订阅统计与分享查询参数。
"""
params = {
"page": page,
"count": count,
**extra,
}
if genre_id is not None:
params["genre_id"] = genre_id
if min_rating is not None:
params["min_rating"] = min_rating
if max_rating is not None:
params["max_rating"] = max_rating
if sort_type is not None:
params["sort_type"] = sort_type
return params
@classmethod
@cached(region="subscribe_share", maxsize=5, ttl=1800, skip_empty=True)
def get_subscribe_statistic(
cls,
stype: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
genre_id: Optional[int] = None,
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
) -> List[dict]:
"""
获取订阅统计数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
params = cls._build_subscribe_query_params(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
stype=stype,
)
return cls._handle_list_response(cls.subscribe_statistic(params))
@classmethod
@cached(region="subscribe_share", maxsize=5, ttl=1800, skip_empty=True)
async def async_get_subscribe_statistic(
cls,
stype: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
genre_id: Optional[int] = None,
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
) -> List[dict]:
"""
异步获取订阅统计数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
params = cls._build_subscribe_query_params(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
stype=stype,
)
return cls._handle_list_response(await cls.async_subscribe_statistic(params))
@classmethod
def sub_reg(cls, sub: dict) -> bool:
"""
新增订阅统计。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = cls.subscribe_add(sub)
return bool(res is not None and res.status_code == 200)
@classmethod
async def async_sub_reg(cls, sub: dict) -> bool:
"""
异步新增订阅统计。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = await cls.async_subscribe_add(sub)
return bool(res is not None and res.status_code == 200)
@classmethod
def sub_done(cls, sub: dict) -> bool:
"""
完成订阅统计。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = cls.subscribe_done(sub)
return bool(res is not None and res.status_code == 200)
@classmethod
def sub_reg_async(cls, sub: dict) -> bool:
"""
开线程新增订阅统计。
"""
Thread(target=cls.sub_reg, args=(sub,)).start()
return True
@classmethod
def sub_done_async(cls, sub: dict) -> bool:
"""
开线程完成订阅统计。
"""
Thread(target=cls.sub_done, args=(sub,)).start()
return True
@classmethod
def sub_report(cls) -> bool:
"""
上报存量订阅统计。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
subscribes = SubscribeOper().list()
if not subscribes:
return True
res = cls.subscribe_report([sub.to_dict() for sub in subscribes])
return bool(res is not None and res.status_code == 200)
@classmethod
def sub_share(
cls,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
分享订阅。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
subscribe = SubscribeOper().get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
subscribe_dict = subscribe.to_dict()
subscribe_dict.pop("id", None)
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe_dict,
}
return cls._handle_response(cls.subscribe_share(payload), cls._clear_subscribe_share_cache)
@classmethod
async def async_sub_share(
cls,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
异步分享订阅。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
subscribe = await SubscribeOper().async_get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
subscribe_dict = subscribe.to_dict()
subscribe_dict.pop("id", None)
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe_dict,
}
return cls._handle_response(
await cls.async_subscribe_share(payload),
cls._clear_subscribe_share_cache,
)
@classmethod
def share_delete(cls, share_id: int) -> Tuple[bool, str]:
"""
删除订阅分享。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
return cls._handle_response(
cls.subscribe_share_delete(share_id, cls.get_user_uuid()),
cls._clear_subscribe_share_cache,
)
@classmethod
async def async_share_delete(cls, share_id: int) -> Tuple[bool, str]:
"""
异步删除订阅分享。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
return cls._handle_response(
await cls.async_subscribe_share_delete(share_id, cls.get_user_uuid()),
cls._clear_subscribe_share_cache,
)
@classmethod
def sub_fork(cls, share_id: int) -> Tuple[bool, str]:
"""
复用订阅分享。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
return cls._handle_response(cls.subscribe_fork(share_id))
@classmethod
async def async_sub_fork(cls, share_id: int) -> Tuple[bool, str]:
"""
异步复用订阅分享。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False, "当前没有开启订阅数据共享功能"
return cls._handle_response(await cls.async_subscribe_fork(share_id))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
def get_subscribe_shares(
cls,
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
genre_id: Optional[int] = None,
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
) -> List[dict]:
"""
获取订阅分享数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
params = cls._build_subscribe_query_params(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
name=name,
)
return cls._handle_list_response(cls.subscribe_shares(params))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
async def async_get_subscribe_shares(
cls,
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
genre_id: Optional[int] = None,
min_rating: Optional[float] = None,
max_rating: Optional[float] = None,
sort_type: Optional[str] = None,
) -> List[dict]:
"""
异步获取订阅分享数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
params = cls._build_subscribe_query_params(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
name=name,
)
return cls._handle_list_response(await cls.async_subscribe_shares(params))
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
def get_subscribe_share_statistics(cls) -> List[dict]:
"""
获取订阅分享统计数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
return cls._handle_list_response(cls.subscribe_share_statistics())
@classmethod
@cached(region="subscribe_share", maxsize=1, ttl=1800, skip_empty=True)
async def async_get_subscribe_share_statistics(cls) -> List[dict]:
"""
异步获取订阅分享统计数据。
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
return cls._handle_list_response(await cls.async_subscribe_share_statistics())
@classmethod
def workflow_share(cls, payload: Dict[str, Any]):
"""
分享工作流数据。
"""
return cls._post_json(cls._server_url(cls._WORKFLOW_SHARE_PATH), payload, timeout=10)
@classmethod
async def async_workflow_share(cls, payload: Dict[str, Any]):
"""
异步分享工作流数据。
"""
return await cls._async_post_json(cls._server_url(cls._WORKFLOW_SHARE_PATH), payload, timeout=10)
@classmethod
def workflow_share_delete(cls, share_id: int, share_uid: str):
"""
删除工作流分享数据。
"""
return cls._delete(
f"{cls._server_url(cls._WORKFLOW_SHARE_PATH)}/{share_id}",
params={"share_uid": share_uid},
timeout=5,
)
@classmethod
async def async_workflow_share_delete(cls, share_id: int, share_uid: str):
"""
异步删除工作流分享数据。
"""
return await cls._async_delete(
f"{cls._server_url(cls._WORKFLOW_SHARE_PATH)}/{share_id}",
params={"share_uid": share_uid},
timeout=5,
)
@classmethod
def workflow_fork(cls, share_id: int):
"""
复用工作流分享数据。
"""
return cls._get(f"{cls._server_url(cls._WORKFLOW_FORK_PATH)}/{share_id}", timeout=5)
@classmethod
async def async_workflow_fork(cls, share_id: int):
"""
异步复用工作流分享数据。
"""
return await cls._async_get(f"{cls._server_url(cls._WORKFLOW_FORK_PATH)}/{share_id}", timeout=5)
@classmethod
def workflow_shares(cls, params: Dict[str, Any]):
"""
获取工作流分享数据。
"""
return cls._get(cls._server_url(cls._WORKFLOW_SHARES_PATH), params=params, timeout=15)
@classmethod
async def async_workflow_shares(cls, params: Dict[str, Any]):
"""
异步获取工作流分享数据。
"""
return await cls._async_get(cls._server_url(cls._WORKFLOW_SHARES_PATH), params=params, timeout=15)
@staticmethod
def _prepare_workflow_data(workflow) -> dict:
"""
准备工作流分享数据。
"""
workflow_dict = workflow.to_dict()
workflow_dict.pop("id", None)
workflow_dict.pop("context", None)
workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or [])
workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or [])
return workflow_dict
@classmethod
def workflow_share_by_id(
cls,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
分享工作流。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
workflow = WorkflowOper().get(workflow_id)
valid, message = cls._validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**cls._prepare_workflow_data(workflow),
}
return cls._handle_response(cls.workflow_share(payload), cls._clear_workflow_share_cache)
@classmethod
async def async_workflow_share_by_id(
cls,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
异步分享工作流。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
workflow = await WorkflowOper().async_get(workflow_id)
valid, message = cls._validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**cls._prepare_workflow_data(workflow),
}
return cls._handle_response(
await cls.async_workflow_share(payload),
cls._clear_workflow_share_cache,
)
@classmethod
def workflow_share_delete_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
删除工作流分享。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
return cls._handle_response(
cls.workflow_share_delete(share_id, cls.get_user_uuid()),
cls._clear_workflow_share_cache,
)
@classmethod
async def async_workflow_share_delete_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
异步删除工作流分享。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
return cls._handle_response(
await cls.async_workflow_share_delete(share_id, cls.get_user_uuid()),
cls._clear_workflow_share_cache,
)
@classmethod
def workflow_fork_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
复用工作流分享。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
return cls._handle_response(cls.workflow_fork(share_id))
@classmethod
async def async_workflow_fork_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
异步复用工作流分享。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return False, "当前没有开启工作流数据共享功能"
return cls._handle_response(await cls.async_workflow_fork(share_id))
@classmethod
@cached(region="workflow_share", maxsize=1, skip_empty=True)
def get_workflow_shares(
cls,
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
) -> List[dict]:
"""
获取工作流分享数据。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return []
return cls._handle_list_response(cls.workflow_shares({
"name": name,
"page": page,
"count": count,
}))
@classmethod
@cached(region="workflow_share", maxsize=1, skip_empty=True)
async def async_get_workflow_shares(
cls,
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
) -> List[dict]:
"""
异步获取工作流分享数据。
"""
if not settings.WORKFLOW_STATISTIC_SHARE:
return []
return cls._handle_list_response(await cls.async_workflow_shares({
"name": name,
"page": page,
"count": count,
}))
@staticmethod
def _validate_workflow(workflow) -> Tuple[bool, str]:
"""
验证工作流是否可以分享。
"""
if not workflow:
return False, "工作流不存在"
if not workflow.actions or not workflow.flows:
return False, "请分享有动作和流程的工作流"
return True, ""
@classmethod
def recognize_share_url(cls) -> Optional[str]:
"""
获取共享识别服务端地址。
"""
custom_api = (settings.MEDIA_RECOGNIZE_SHARE_API or "").strip()
if custom_api:
return custom_api.rstrip("/")
server_host = (settings.MP_SERVER_HOST or "").strip().rstrip("/")
if not server_host:
return None
return f"{server_host}{cls._RECOGNIZE_SHARE_PATH}"
@classmethod
def recognize_query(cls, params: Dict[str, Any]):
"""
查询共享识别结果。
"""
api_url = cls.recognize_share_url()
if not api_url:
return None
return cls._get(api_url, params=params, timeout=5)
@classmethod
async def async_recognize_query(cls, params: Dict[str, Any]):
"""
异步查询共享识别结果。
"""
api_url = cls.recognize_share_url()
if not api_url:
return None
return await cls._async_get(api_url, params=params, timeout=5)
@classmethod
def recognize_report(cls, payload: Dict[str, Any]):
"""
上报共享识别结果。
"""
api_url = cls.recognize_share_url()
if not api_url:
return None
return cls._post_json(api_url, payload, timeout=5)
@classmethod
async def async_recognize_report(cls, payload: Dict[str, Any]):
"""
异步上报共享识别结果。
"""
api_url = cls.recognize_share_url()
if not api_url:
return None
return await cls._async_post_json(api_url, payload, timeout=5)
@classmethod
def query_recognize_share(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict]:
"""
查询共享识别结果。
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return None
params = cls._build_recognize_query_params(
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
)
if not params:
return None
response = cls.recognize_query(params)
return cls._parse_recognize_response(response, params.get("keyword"))
@classmethod
async def async_query_recognize_share(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict]:
"""
异步查询共享识别结果。
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return None
params = cls._build_recognize_query_params(
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
)
if not params:
return None
response = await cls.async_recognize_query(params)
return cls._parse_recognize_response(response, params.get("keyword"))
@classmethod
def report_recognize_share(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
上报共享识别结果。
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return False
payload = cls._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
if not payload:
return False
response = cls.recognize_report(payload)
return cls._parse_recognize_report_response(response)
@classmethod
async def async_report_recognize_share(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
异步上报共享识别结果。
"""
if not settings.MEDIA_RECOGNIZE_SHARE:
return False
payload = cls._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
if not payload:
return False
response = await cls.async_recognize_report(payload)
return cls._parse_recognize_report_response(response)
@classmethod
def to_recognize_params(cls, item: Optional[dict]) -> Optional[dict]:
"""
将服务端返回的共享识别结果转成本地识别参数。
"""
if not isinstance(item, dict):
return None
media_type = cls._normalize_media_type(item.get("type"))
mtype = MediaType.from_agent(media_type) if media_type else None
tmdbid = item.get("tmdbid")
doubanid = item.get("doubanid")
bangumiid = item.get("bangumiid")
if not any([tmdbid, doubanid, bangumiid]):
return None
return {
"mtype": mtype,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"season": item.get("season"),
}
@classmethod
def sanitize_plugin_repo_url(cls, repo_url: Optional[str]) -> Optional[str]:
"""
统计上报前脱敏插件仓库地址。
"""
if not repo_url:
return repo_url
if not repo_url.startswith(cls._LOCAL_REPO_PREFIX):
return repo_url
plugin_id = cls._parse_local_repo_plugin_id(repo_url)
if not plugin_id:
return cls._LOCAL_REPO_PREFIX.rstrip("/")
return cls._make_local_repo_url(
plugin_id=plugin_id,
package_version=cls._parse_local_repo_package_version(repo_url),
)
@classmethod
def _normalize_media_type(cls, media_type: Optional[object]) -> Optional[str]:
"""
统一媒体类型,兼容枚举、中文值和 agent 风格字符串。
"""
normalized = media_type_to_agent(media_type)
if normalized in {"movie", "tv"}:
return normalized
if isinstance(media_type, str):
if media_type == MediaType.MOVIE.value:
return "movie"
if media_type == MediaType.TV.value:
return "tv"
return None
@staticmethod
def _extract_keyword(meta: Optional[MetaBase]) -> Optional[str]:
"""
提取识别关键字。
"""
if not meta:
return None
keyword = meta.original_name or meta.name
if keyword:
keyword = str(keyword).strip()
return keyword or None
@classmethod
def _extract_media_type(
cls,
meta: Optional[MetaBase] = None,
mtype: Optional[MediaType] = None,
mediainfo: Optional[MediaInfo] = None,
) -> Optional[str]:
"""
提取媒体类型。
"""
media_type = cls._normalize_media_type(mtype)
if media_type:
return media_type
if mediainfo and mediainfo.type in {MediaType.MOVIE, MediaType.TV}:
return mediainfo.type.to_agent()
if meta and meta.type in {MediaType.MOVIE, MediaType.TV}:
return meta.type.to_agent()
if meta and (meta.begin_season is not None or meta.begin_episode is not None):
return "tv"
return None
@classmethod
def _extract_season(
cls,
media_type: Optional[str],
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
) -> Optional[int]:
"""
提取季信息,仅电视剧使用。
"""
if media_type != "tv":
return None
season = meta.begin_season if meta else None
if season is None and mediainfo:
season = mediainfo.season
try:
return int(season) if season is not None else None
except (TypeError, ValueError):
return None
@staticmethod
def _extract_year(
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
) -> Optional[str]:
"""
提取年份。
"""
year = (meta.year if meta else None) or (mediainfo.year if mediainfo else None)
if year is None:
return None
year_text = str(year).strip()
return year_text or None
@classmethod
def _build_recognize_query_params(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict]:
"""
组装共享识别查询参数。
"""
keyword = cls._extract_keyword(keyword_meta or meta)
if not keyword:
return None
media_type = cls._extract_media_type(meta=meta, mtype=mtype)
params = {"keyword": keyword}
if media_type:
params["type"] = media_type
if year := cls._extract_year(meta=meta):
params["year"] = year
if season := cls._extract_season(media_type=media_type, meta=meta):
params["season"] = season
return params
@classmethod
def _build_recognize_report_payload(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict]:
"""
组装共享识别上报载荷。
"""
if not meta or not mediainfo:
return None
keyword = cls._extract_keyword(keyword_meta or meta)
media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo)
if not keyword or not media_type:
return None
if not any([mediainfo.tmdb_id, mediainfo.douban_id, mediainfo.bangumi_id]):
return None
return {
"keyword": keyword,
"type": media_type,
"title": mediainfo.title or keyword,
"year": cls._extract_year(meta=meta, mediainfo=mediainfo),
"season": cls._extract_season(
media_type=media_type,
meta=meta,
mediainfo=mediainfo,
),
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
}
@classmethod
def _parse_recognize_response(cls, response, keyword: Optional[str]) -> Optional[dict]:
"""
解析共享识别查询响应。
"""
if not response or response.status_code != 200:
if response is not None:
logger.warn(
f"查询共享媒体识别失败status={response.status_code} "
f"message={cls._response_message(response)}"
)
return None
try:
payload = response.json()
except (json.JSONDecodeError, ValueError) as err:
logger.warn(f"解析共享媒体识别响应失败:{err}")
return None
if payload.get("code") != 0:
return None
item = cls._parse_response_item(payload)
if item:
logger.info(f"共享媒体识别命中:{keyword} - {item}")
return item
@classmethod
def _parse_recognize_report_response(cls, response) -> bool:
"""
解析共享识别上报响应。
"""
if not response or response.status_code != 200:
if response is not None:
logger.warn(
f"上报共享媒体识别失败status={response.status_code} "
f"message={cls._response_message(response)}"
)
return False
try:
result = response.json()
except (json.JSONDecodeError, ValueError) as err:
logger.warn(f"解析共享媒体识别上报响应失败:{err}")
return False
return result.get("code") == 0
@staticmethod
def _parse_response_item(data: Optional[dict]) -> Optional[dict]:
"""
解析服务端返回的共享识别数据。
"""
if not isinstance(data, dict):
return None
item = (data.get("data") or {}).get("item")
if not isinstance(item, dict):
return None
return item
@staticmethod
def _response_message(response) -> str:
"""
获取响应消息,兼容非 JSON 响应。
"""
try:
payload = response.json()
return str(payload.get("message") or "")
except (json.JSONDecodeError, ValueError, AttributeError):
return ""
@classmethod
def _build_plugin_report_payload(
cls,
items: Optional[List[Tuple[str, Optional[str]]]] = None,
) -> List[Dict[str, Any]]:
"""
构建批量插件安装统计载荷。
"""
if items:
return [
{
"plugin_id": plugin_id,
"repo_url": cls.sanitize_plugin_repo_url(repo_url),
}
for plugin_id, repo_url in items
if plugin_id
]
plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins)
if not plugins:
return []
return [{"plugin_id": plugin, "repo_url": None} for plugin in plugins]
@classmethod
def _parse_local_repo_plugin_id(cls, repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析插件 ID。
"""
try:
parts = urlsplit(repo_url)
plugin_id = parts.netloc or parts.path.strip("/")
except Exception:
plugin_id = repo_url[len(cls._LOCAL_REPO_PREFIX):].split("?", 1)[0].strip("/")
return plugin_id or None
@staticmethod
def _parse_local_repo_package_version(repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析 package 版本。
"""
try:
values = parse_qs(urlsplit(repo_url).query).get("version")
if not values:
return None
return values[0]
except Exception:
return None
@classmethod
def _make_local_repo_url(
cls,
plugin_id: str,
package_version: Optional[str] = None,
) -> str:
"""
生成脱敏后的本地插件来源标识。
"""
repo_url = f"{cls._LOCAL_REPO_PREFIX}{quote(plugin_id, safe='')}"
if package_version:
repo_url = f"{repo_url}?version={quote(package_version, safe='')}"
return repo_url
@classmethod
def _clear_subscribe_share_cache(cls) -> None:
"""
清理订阅共享相关缓存。
"""
cls.get_subscribe_shares.cache_clear()
cls.async_get_subscribe_shares.cache_clear()
cls.get_subscribe_statistic.cache_clear()
cls.async_get_subscribe_statistic.cache_clear()
cls.get_subscribe_share_statistics.cache_clear()
cls.async_get_subscribe_share_statistics.cache_clear()
@classmethod
def _clear_workflow_share_cache(cls) -> None:
"""
清理工作流共享相关缓存。
"""
cls.get_workflow_shares.cache_clear()
cls.async_get_workflow_shares.cache_clear()
@classmethod
def _has_header(cls, headers: dict, name: str) -> bool:
"""
按 HTTP 头大小写不敏感规则判断请求头是否存在。
"""
header_name = name.lower()
return any(str(key).lower() == header_name for key in headers)
@staticmethod
def _server_url(path: str) -> str:
"""
根据服务端基础地址和路径生成完整 URL。
"""
return f"{settings.MP_SERVER_HOST.rstrip('/')}{path}"
@classmethod
def _get(
cls,
url: str,
params: Optional[dict] = None,
timeout: int = 10,
include_user_uid: bool = True,
):
"""
发送服务端 GET 请求,默认携带安装用户 ID。
"""
return RequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url) if include_user_uid else {},
).get_res(url, params=params)
@classmethod
async def _async_get(
cls,
url: str,
params: Optional[dict] = None,
timeout: int = 10,
include_user_uid: bool = True,
):
"""
异步发送服务端 GET 请求,默认携带安装用户 ID。
"""
return await AsyncRequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url) if include_user_uid else {},
).get_res(url, params=params)
@classmethod
def _post_json(cls, url: str, payload: Dict[str, Any], timeout: int = 10):
"""
发送携带安装用户 ID 的服务端 JSON POST 请求。
"""
return RequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url, content_type="application/json"),
).post(url, json=payload)
@classmethod
async def _async_post_json(cls, url: str, payload: Dict[str, Any], timeout: int = 10):
"""
异步发送携带安装用户 ID 的服务端 JSON POST 请求。
"""
return await AsyncRequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url, content_type="application/json"),
).post(url, json=payload)
@classmethod
def _delete(cls, url: str, params: Optional[dict] = None, timeout: int = 10):
"""
发送携带安装用户 ID 的服务端 DELETE 请求。
"""
return RequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url),
).delete_res(url, params=params)
@classmethod
async def _async_delete(cls, url: str, params: Optional[dict] = None, timeout: int = 10):
"""
异步发送携带安装用户 ID 的服务端 DELETE 请求。
"""
return await AsyncRequestUtils(
proxies=settings.PROXY,
timeout=timeout,
headers=cls.build_headers(url),
).delete_res(url, params=params)