Files

2352 lines
81 KiB
Python
Raw Permalink 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.
"""
封装 MoviePilot 中心服务请求、响应解析,以及供旧调用方使用的上报和分享入口。
"""
import asyncio
import json
import platform
from collections.abc import Coroutine
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
ParamSpec,
Protocol,
Tuple,
TypeVar,
Union,
cast,
)
from urllib.parse import parse_qs, quote, urlparse, urlsplit, urlunsplit
from httpx2 import Response as AsyncResponse
from requests import Response
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.adapters.system.host import SystemUtils
from app.domain.context import MediaInfo, MusicInfo
from app.domain.media import normalize_music_type
from app.domain.meta.metabase import MetaBase
from app.runtime.cache import cached
from app.runtime.log import logger
from app.runtime.loop import main_loop_registry
from app.runtime.observability import observe_compat_facade
from app.runtime.settings import get_runtime_setting
from app.runtime.tasks import get_task_registry
from app.runtime.version import get_app_version, get_frontend_version
from app.schemas.media import resolve_media_identity
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
SystemConfigKey,
media_type_to_agent,
)
if TYPE_CHECKING:
from app.application.server.report import ServerReportService
from app.application.server.share import ServerSharingService
from app.application.workflow import WorkflowSnapshot
# 中心服务适配器只通过 runtime 读取端口消费组合根的最新配置。
_LookupParams = ParamSpec("_LookupParams")
_LookupResult = TypeVar("_LookupResult")
class _CacheControl(Protocol):
"""
缓存装饰器为包装函数提供的失效接口。
"""
def cache_clear(self) -> None:
"""
清空此包装函数对应的缓存区域;不发起服务端请求。
"""
...
def _cached_server_result(
*, region: Optional[str] = None, maxsize: int = 1024,
ttl: Optional[int] = None, skip_empty: bool = False,
) -> Callable[[Callable[_LookupParams, _LookupResult]], Callable[_LookupParams, _LookupResult]]:
"""
保留统计和分享查询的参数、结果类型,沿用底层缓存策略。
:param region: 原有缓存区域名称;未指定时按被包装函数生成
:param maxsize: 缓存条目上限
:param ttl: 条目有效秒数;未指定时沿用底层默认值
:param skip_empty: 是否跳过空结果
:return: 不改变调用签名的缓存装饰器
"""
def decorator(function: Callable[_LookupParams, _LookupResult]) -> Callable[_LookupParams, _LookupResult]:
"""
恢复底层包装器保持不变的原始调用签名。
"""
return cached(region=region, maxsize=maxsize, ttl=ttl, skip_empty=skip_empty)(function)
return decorator
def _clear_server_cache(function: Callable[..., object]) -> None:
"""
清空由缓存装饰器附加的区域,不改变原有失效顺序。
"""
cast(_CacheControl, function).cache_clear()
def _response_mapping(response: Response | AsyncResponse) -> dict[str, Any]:
"""
在外部 JSON 边界确认对象形状,保留协议允许的任意字段内容。
:param response: 已取得的同步或异步 HTTP 响应
:return: 原始 JSON 对象,不复制或丢弃字段
:raises ValueError: JSON 解析失败或顶层不是对象
"""
payload: object = response.json()
if not isinstance(payload, dict):
raise ValueError("中心服务响应不是 JSON 对象")
return payload
def _cached_server_lookup(
maxsize: int,
) -> Callable[
[Callable[_LookupParams, Awaitable[Dict[str, Any]]]],
Callable[_LookupParams, Awaitable[Dict[str, Any]]],
]:
"""
为中心服务开关与权限查询提供保留签名的短时进程内缓存。
成功结果缓存 60 秒,使服务端开关与名单变更在一分钟内生效;不可达或非 200 得到的
空结果只缓存 15 秒,故障恢复后尽快重新判定。结果随本实例身份变化,不共享到 Redis。
"""
def decorator(
function: Callable[_LookupParams, Awaitable[Dict[str, Any]]],
) -> Callable[_LookupParams, Awaitable[Dict[str, Any]]]:
"""
包装单个查询函数,缓存层本身未标注类型,这里显式恢复原签名。
"""
return cached(maxsize=maxsize, ttl=60, empty_ttl=15, local_only=True)(function)
return decorator
_server_report_service: ServerReportService | None = None
_server_sharing_service: ServerSharingService | None = None
def configure_server_application_services(
*,
report_service: ServerReportService,
sharing_service: ServerSharingService,
) -> None:
"""
由启动组合根注入分享和存量上报应用服务。
"""
global _server_report_service, _server_sharing_service
_server_report_service = report_service
_server_sharing_service = sharing_service
def reset_server_application_services() -> None:
"""
清除当前应用生命周期的中心服务上报与分享用例。
"""
global _server_report_service, _server_sharing_service
_server_report_service = None
_server_sharing_service = None
@observe_compat_facade("MoviePilotServerHelper")
class MoviePilotServerHelper:
"""
MoviePilot 服务端请求辅助工具。
中心服务请求统一使用 HTTP/1.1,用于对照验证间歇性请求失败是否与 HTTP/2 链路有关。
"""
USER_UID_HEADER = "X-MoviePilot-User-Uid"
_USAGE_REPORT_PATH = "/usage/report"
_USAGE_STATISTIC_PATH = "/usage/statistic"
_PLUGIN_INSTALL_PATH = "/plugin/install"
_PLUGIN_RATING_PATH = "/plugin/rating"
_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"
_USER_FEATURES_PATH = "/user/features"
_LOCAL_REPO_PREFIX = "local://"
_user_uid: Optional[str] = None
_github_user: Optional[str] = None
@staticmethod
def normalize_relay_url(server: str) -> str:
"""
校验中转地址并统一持久凭据的作用域,不允许凭据、查询参数或片段。
:param server: 管理员配置的 HTTPS 服务根地址,回环调试允许 HTTP
:return: 规范化且去除尾部斜线的地址
:raises ValueError: 地址不能安全接收实例凭据
"""
target = urlsplit(server)
local = target.hostname in {"localhost", "127.0.0.1", "::1"}
if (target.scheme != "https" and not (local and target.scheme == "http")) or not target.hostname or target.username or target.password or target.query or target.fragment:
raise ValueError("中转地址必须使用 HTTPS,本机回环调试除外")
return urlunsplit((target.scheme, target.netloc.lower(), target.path.rstrip("/"), "", ""))
@staticmethod
def relay_push(server: str, key: str, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]:
"""
向显式配置的私有 Server 转发通知,发送密钥只放请求头且不跟随重定向。
:param server: 管理员配置的 HTTPS 中转地址;本机联调允许回环 HTTP
:param key: 该实例专用的中转发送密钥
:param method: 内部限定的 GET、POST 或 DELETE 方法
:param path: 内部限定的 push 接口路径
:param data: 已绑定当前用户的注册或消息数据
:return: Server 返回的对象;失败抛出不含凭据或原始响应的 ValueError
"""
server = MoviePilotServerHelper.normalize_relay_url(server)
allowed = {("GET", "devices"), ("POST", "devices"), ("DELETE", "devices"),
("POST", "pairings"), ("POST", "messages"), ("POST", "instances"),
("PUT", "instances/credentials"), ("DELETE", "instances")}
if (method, path) not in allowed:
raise ValueError("不支持的通知接口")
if len(key) < 32:
raise ValueError("请配置至少 32 位的中转发送密钥")
# 首次登记或恢复轮换之后还要执行原操作,两段请求均需落在 App 的65秒截止时间内。
response = RequestUtils(timeout=20, headers={"Authorization": f"Bearer {key}"}).request(
method, f"{server.rstrip('/')}/push/{path}", allow_redirects=False, verify=True,
params=data if method == "GET" else None,
json=data if method != "GET" else None,
)
if response is None or response.status_code != 200:
code = response.status_code if response is not None else 0
if code == 403 and path == "instances":
raise ValueError("消息中心尚未开放自动接入,请联系中心运营者")
if code == 401:
raise ValueError("通知接入凭据无效或已停用,请联系中心运营者")
if code == 429:
raise ValueError("消息中心请求过于频繁,请稍后重试")
raise ValueError(f"私有通知服务请求失败({code})")
try:
return _response_mapping(response)
except ValueError as error:
raise ValueError("私有通知服务响应无效") from error
@classmethod
def _report_service(cls) -> ServerReportService:
"""
返回启动组合根注入的存量上报应用服务。
"""
if _server_report_service is None:
raise RuntimeError("中心服务上报用例尚未由启动组合根装配")
return _server_report_service
@classmethod
def _sharing_service(cls) -> ServerSharingService:
"""
返回启动组合根注入的订阅和工作流分享应用服务。
"""
if _server_sharing_service is None:
raise RuntimeError("中心服务分享用例尚未由启动组合根装配")
return _server_sharing_service
@classmethod
def get_user_uid(cls) -> Optional[str]:
"""
获取当前安装实例用于服务端统计识别的稳定用户 ID。
"""
if cls._user_uid is None:
# 底层兼容工具尚未标注签名;实现始终返回哈希字符串或 None。
uid_provider: Callable[[], Optional[str]] = SystemUtils.generate_user_unique_id
cls._user_uid = uid_provider()
return cls._user_uid
@staticmethod
def is_server_url(url: str) -> bool:
"""
判断请求地址是否指向配置中的 MoviePilot 服务端。
"""
server_host = (get_runtime_setting('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[str, Any]] = None,
content_type: Optional[str] = None,
) -> dict[str, Any]:
"""
构建访问 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"] = get_runtime_setting('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 用户名。
"""
plan = cls._build_github_user_request()
if plan:
res = RequestUtils(**plan).get_res("https://api.github.com/user")
cls._remember_github_user(res)
return cls._github_user or ""
@classmethod
async def async_get_github_user(cls) -> str:
"""
异步获取当前 GitHub 用户名。
"""
plan = cls._build_github_user_request()
if plan:
res = await AsyncRequestUtils(**plan).get_res("https://api.github.com/user")
cls._remember_github_user(res)
return cls._github_user or ""
@classmethod
def _build_github_user_request(cls) -> Optional[Dict[str, Any]]:
"""
生成 GitHub 当前用户查询计划,已有缓存或未配置凭据时不发请求。
"""
headers = get_runtime_setting('GITHUB_HEADERS')
if cls._github_user is not None or not headers:
return None
return {
"headers": headers,
"proxies": get_runtime_setting('PROXY'),
"timeout": 15,
}
@classmethod
def _remember_github_user(cls, response: Response | AsyncResponse | None) -> None:
"""
统一解释 GitHub 用户响应并更新同步异步共享的用户名缓存。
"""
if response:
cls._github_user = response.json().get("login")
logger.info(f"当前Github用户: {cls._github_user}")
@classmethod
def user_permissions(cls, github_user: str) -> Optional[Response]:
"""
查询服务端用户权限。
"""
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) -> Optional[AsyncResponse]:
"""
异步查询服务端用户权限。
"""
return await cls._async_get(
cls._server_url(cls._USER_PERMISSIONS_PATH),
params={"github_user": github_user},
include_user_uid=False,
timeout=5,
)
@classmethod
async def async_user_features(cls) -> Any:
"""
异步查询服务端对所有实例相同的全局功能开关。
"""
return await cls._async_get(
cls._server_url(cls._USER_FEATURES_PATH),
include_user_uid=False,
timeout=5,
)
@classmethod
@_cached_server_lookup(maxsize=1)
async def async_get_features(cls) -> Dict[str, Any]:
"""
获取服务端全局功能开关(如 ``community_medals``),失败时返回空对象。
结果与实例身份无关,用单一键缓存:成功 60 秒,使开关变更在一分钟内生效;服务端
不可达或非 200 得到的空结果只缓存 15 秒。仅缓存于进程内存。
"""
try:
return cls._handle_mapping_response(await cls.async_user_features())
except Exception as err:
logger.debug(f"获取服务端全局功能开关失败:{str(err)}")
return {}
@classmethod
def get_user_permissions(cls) -> Dict[str, Any]:
"""
获取当前用户在服务端配置中的权限。
"""
github_user = cls.get_github_user()
if not github_user:
return {}
try:
return cls._handle_mapping_response(cls.user_permissions(github_user))
except Exception as err:
logger.debug(f"获取服务端用户权限失败:{str(err)}")
return {}
@classmethod
async def async_get_user_permissions(cls) -> Dict[str, Any]:
"""
异步获取当前用户在服务端配置中的权限;未登录 GitHub 时不发请求并返回空权限。
"""
github_user = await cls.async_get_github_user()
if not github_user:
return {}
return await cls._async_fetch_user_permissions(github_user)
@classmethod
@_cached_server_lookup(maxsize=4)
async def _async_fetch_user_permissions(cls, github_user: str) -> Dict[str, Any]:
"""
按 GitHub 用户名查询并短时缓存服务端权限。
前端每次加载会同时请求 ``/system/global/user`` 与 ``/github/community``,两者都依赖
此结果(共享管理权限与社区勋章预览),短时缓存避免每次页面加载向服务端重复往返。
缓存键为用户名,换账号后不沿用旧结论。成功结果缓存 60 秒,使服务端开关与管理员名单变更在一分钟内生效;服务端
不可达或非 200 得到的空权限只缓存 15 秒,故障恢复后尽快重新判定。仅缓存于进程内存,
权限随本实例 GitHub 身份变化,不共享到 Redis。
"""
try:
return cls._handle_mapping_response(
await cls.async_user_permissions(github_user)
)
except Exception as err:
logger.debug(f"获取服务端用户权限失败:{str(err)}")
return {}
@classmethod
def is_admin_user(cls) -> bool:
"""
判断当前用户是否为共享管理用户。
"""
return cls._permissions_allow_sharing(cls.get_user_permissions())
@classmethod
async def async_is_admin_user(cls) -> bool:
"""
异步判断当前用户是否为共享管理用户。
"""
return cls._permissions_allow_sharing(
await cls.async_get_user_permissions()
)
@staticmethod
def _permissions_allow_sharing(permissions: Dict[str, Any]) -> bool:
"""
按统一权限字段判断当前用户是否可管理共享内容。
"""
return bool(
permissions.get("is_admin")
or permissions.get("subscribe_share_manage")
or permissions.get("workflow_share_manage")
)
@classmethod
def build_usage_payload(cls) -> Dict[str, Any]:
"""
构建安装版本统计上报载荷。
"""
return {
"user_uid": cls.get_user_uid(),
"backend_version": get_app_version(),
"frontend_version": get_frontend_version(),
"version_flag": get_runtime_setting('VERSION_FLAG'),
"platform": f"{platform.system()} {platform.release()}".strip(),
"arch": SystemUtils.cpu_arch(),
}
@classmethod
def report_usage(cls) -> bool:
"""
上报当前安装实例的版本统计。
"""
payload = cls._build_usage_report_plan()
if not payload:
return False
try:
return cls._response_succeeded(cls.usage_report(payload))
except Exception as err:
logger.debug(f"上报安装版本统计失败:{str(err)}")
return False
@classmethod
async def async_report_usage(cls) -> bool:
"""
异步上报当前安装实例的版本统计。
"""
payload = cls._build_usage_report_plan()
if not payload:
return False
try:
return cls._response_succeeded(await cls.async_usage_report(payload))
except Exception as err:
logger.debug(f"上报安装版本统计失败:{str(err)}")
return False
@classmethod
def _build_usage_report_plan(cls) -> Optional[Dict[str, Any]]:
"""
在统计已启用且实例身份有效时生成版本统计载荷。
"""
if not get_runtime_setting('USAGE_STATISTIC_SHARE'):
return None
payload = cls.build_usage_payload()
return payload if payload.get("user_uid") else None
@classmethod
async def async_get_usage_statistic(cls) -> Dict[str, Any]:
"""
异步获取安装版本统计报表。
"""
if not get_runtime_setting('USAGE_STATISTIC_SHARE'):
return {}
try:
return cls._handle_mapping_response(await cls.async_usage_statistic())
except Exception as err:
logger.debug(f"异步获取安装版本统计报表失败:{str(err)}")
return {}
@classmethod
def init_subscribe_report(cls) -> None:
"""
初始化订阅统计上报状态。
"""
cls._report_service().init_report(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
state_key=SystemConfigKey.SubscribeReport,
reporter=cls.sub_report,
)
@classmethod
async def async_init_subscribe_report(cls) -> None:
"""
异步初始化订阅统计标记。
"""
await cls._report_service().async_init_report(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
state_key=SystemConfigKey.SubscribeReport,
reporter=cls.async_sub_report,
)
@classmethod
def init_plugin_report(cls) -> None:
"""
初始化插件安装统计上报状态。
"""
cls._report_service().init_report(
enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'),
state_key=SystemConfigKey.PluginInstallReport,
reporter=cls.install_plugin_report,
)
@classmethod
async def async_init_plugin_report(cls) -> None:
"""
异步初始化插件统计标记。
"""
await cls._report_service().async_init_report(
enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'),
state_key=SystemConfigKey.PluginInstallReport,
reporter=cls.async_install_plugin_report,
)
@staticmethod
def _handle_list_response(res: Response | AsyncResponse | None) -> List[dict[str, Any]]:
"""
处理服务端返回的列表响应。
"""
if res is not None and res.status_code == 200:
payload: object = res.json()
if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload):
raise ValueError("中心服务响应不是 JSON 对象列表")
return payload
return []
@staticmethod
def _handle_mapping_response(res: Response | AsyncResponse | None) -> Dict[str, Any]:
"""
处理服务端返回的对象响应,非成功状态统一映射为空对象。
"""
if res is not None and res.status_code == 200:
return _response_mapping(res)
return {}
@staticmethod
def _response_succeeded(res: Response | AsyncResponse | None) -> bool:
"""
把同步与异步传输响应统一映射为服务端成功状态。
"""
return bool(res is not None and res.status_code == 200)
@staticmethod
def _handle_response(
res: Response | AsyncResponse | None,
clear_cache: Optional[Callable[[], None]] = 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]) -> Optional[Response]:
"""
上报安装版本统计。
"""
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]) -> Optional[AsyncResponse]:
"""
异步上报安装版本统计。
"""
return await cls._async_post_json(cls._server_url(cls._USAGE_REPORT_PATH), payload, timeout=5)
@classmethod
def usage_statistic(cls) -> Optional[Response]:
"""
获取安装版本统计报表。
"""
return cls._get(cls._server_url(cls._USAGE_STATISTIC_PATH), timeout=10)
@classmethod
async def async_usage_statistic(cls) -> Optional[AsyncResponse]:
"""
异步获取安装版本统计报表。
"""
return await cls._async_get(cls._server_url(cls._USAGE_STATISTIC_PATH), timeout=10)
@classmethod
def plugin_statistic(cls) -> Optional[Response]:
"""
获取插件安装统计。
"""
return cls._get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10)
@classmethod
async def async_plugin_statistic(cls) -> Optional[AsyncResponse]:
"""
异步获取插件安装统计。
"""
return await cls._async_get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10)
@classmethod
async def async_plugin_ratings(cls, plugin_ids: Optional[List[str]] = None) -> Optional[AsyncResponse]:
"""
异步批量查询中心端插件评分。
"""
params = {"plugin_ids": ",".join(plugin_ids)} if plugin_ids is not None else None
return await cls._async_get(
cls._server_url(cls._PLUGIN_RATING_PATH),
params=params,
timeout=10,
)
@classmethod
async def async_plugin_rating(cls, plugin_id: str) -> Optional[AsyncResponse]:
"""
异步查询中心端单个插件评分。
"""
return await cls._async_get(
f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}",
timeout=10,
)
@classmethod
async def async_rate_plugin(cls, plugin_id: str, rating: float) -> Optional[AsyncResponse]:
"""
异步提交当前安装实例的插件评分。
"""
return await cls._async_post_json(
f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}",
{"rating": rating},
timeout=10,
)
@classmethod
def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]) -> Optional[Response]:
"""
上报单个插件安装统计。
"""
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]) -> Optional[AsyncResponse]:
"""
异步上报单个插件安装统计。
"""
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]]) -> Optional[Response]:
"""
批量上报插件安装统计。
"""
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]]) -> Optional[AsyncResponse]:
"""
异步批量上报插件安装统计。
"""
return await cls._async_post_json(
cls._server_url(cls._PLUGIN_INSTALL_PATH),
{"plugins": plugins},
timeout=5,
)
@classmethod
@_cached_server_result(maxsize=1, ttl=1800)
def get_plugin_statistic(cls) -> Dict[str, Any]:
"""
获取插件安装统计。
"""
if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'):
return {}
return cls._handle_mapping_response(cls.plugin_statistic())
@classmethod
async def async_get_plugin_statistic(cls) -> Dict[str, Any]:
"""
异步获取插件安装统计。
"""
if not get_runtime_setting('PLUGIN_STATISTIC_SHARE'):
return {}
return cls._handle_mapping_response(await cls.async_plugin_statistic())
@classmethod
async def async_get_plugin_ratings(
cls,
plugin_ids: Optional[List[str]] = None,
) -> Dict[str, Dict[str, Any]]:
"""
批量获取插件评分,中心端不可用时返回空结果。
"""
try:
res = await cls.async_plugin_ratings(plugin_ids)
if res is not None and res.status_code == 200:
payload = _response_mapping(res)
if not all(isinstance(value, dict) for value in payload.values()):
raise ValueError("中心服务评分响应包含非对象记录")
return payload
except Exception as err:
logger.debug(f"批量获取插件评分失败:{str(err)}")
return {}
@classmethod
async def async_get_plugin_rating(cls, plugin_id: str) -> Dict[str, Any]:
"""
获取单个插件评分,中心端不可用时返回零评分。
"""
empty_rating = {
"plugin_id": plugin_id,
"average_rating": 0.0,
"rating_count": 0,
"user_rating": None,
}
try:
res = await cls.async_plugin_rating(plugin_id)
if res is not None and res.status_code == 200:
return _response_mapping(res)
except Exception as err:
logger.debug(f"获取插件 {plugin_id} 评分失败:{str(err)}")
return empty_rating
@classmethod
async def async_submit_plugin_rating(
cls,
plugin_id: str,
rating: float,
) -> Optional[Dict[str, Any]]:
"""
提交插件评分,成功时返回最新评分结果。
"""
try:
res = await cls.async_rate_plugin(plugin_id, rating)
if res is not None and res.status_code == 200:
return _response_mapping(res)
except Exception as err:
logger.debug(f"提交插件 {plugin_id} 评分失败:{str(err)}")
return None
@classmethod
def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
"""
上报单个插件安装统计。
"""
payload = cls._build_plugin_install_plan(plugin_id, repo_url)
if not payload:
return False
return cls._response_succeeded(cls.plugin_install(plugin_id, payload))
@classmethod
async def async_install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
"""
异步上报单个插件安装统计。
"""
payload = cls._build_plugin_install_plan(plugin_id, repo_url)
if not payload:
return False
return cls._response_succeeded(
await cls.async_plugin_install(plugin_id, payload)
)
@classmethod
def _build_plugin_install_plan(
cls,
plugin_id: str,
repo_url: Optional[str],
) -> Optional[Dict[str, Any]]:
"""
在插件统计已启用且插件身份有效时生成单次安装上报载荷。
"""
if not get_runtime_setting('PLUGIN_STATISTIC_SHARE') or not plugin_id:
return None
return {
"plugin_id": plugin_id,
"repo_url": cls.sanitize_plugin_repo_url(repo_url),
}
@classmethod
def install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
"""
批量上报存量插件安装统计。
"""
return cls._report_service().report_plugins(
enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'),
items=items,
)
@classmethod
async def async_install_plugin_report(cls, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
"""
异步批量上报存量插件安装统计。
"""
return await cls._report_service().async_report_plugins(
enabled=get_runtime_setting('PLUGIN_STATISTIC_SHARE'),
items=items,
)
@classmethod
def subscribe_statistic(cls, params: Dict[str, Any]) -> Optional[Response]:
"""
获取订阅统计数据。
"""
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]) -> Optional[AsyncResponse]:
"""
异步获取订阅统计数据。
"""
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]) -> Optional[Response]:
"""
新增订阅统计。
"""
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]) -> Optional[AsyncResponse]:
"""
异步新增订阅统计。
"""
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]) -> Optional[Response]:
"""
完成订阅统计。
"""
return cls._post_json(cls._server_url(cls._SUBSCRIBE_DONE_PATH), payload, timeout=5)
@classmethod
async def async_subscribe_done(cls, payload: Dict[str, Any]) -> Optional[AsyncResponse]:
"""
异步完成订阅统计,并返回可检查 HTTP 状态的响应对象。
"""
return await cls._async_post_json(
cls._server_url(cls._SUBSCRIBE_DONE_PATH),
payload,
timeout=5,
)
@classmethod
async def async_subscribe_report(cls, subscribes: List[Dict[str, Any]]) -> Optional[AsyncResponse]:
"""
异步批量上报存量订阅统计。
"""
return await cls._async_post_json(
cls._server_url(cls._SUBSCRIBE_REPORT_PATH),
{"subscribes": subscribes},
timeout=10,
)
@classmethod
def subscribe_report(cls, subscribes: List[Dict[str, Any]]) -> Optional[Response]:
"""
批量上报存量订阅统计。
"""
return cls._post_json(
cls._server_url(cls._SUBSCRIBE_REPORT_PATH),
{"subscribes": subscribes},
timeout=10,
)
@classmethod
def subscribe_share(cls, payload: Dict[str, Any]) -> Optional[Response]:
"""
分享订阅数据。
"""
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]) -> Optional[AsyncResponse]:
"""
异步分享订阅数据。
"""
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) -> Optional[Response]:
"""
删除订阅分享数据。
"""
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) -> Optional[AsyncResponse]:
"""
异步删除订阅分享数据。
"""
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) -> Optional[Response]:
"""
复用订阅分享数据。
"""
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) -> Optional[AsyncResponse]:
"""
异步复用订阅分享数据。
"""
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]) -> Optional[Response]:
"""
获取订阅分享数据。
"""
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]) -> Optional[AsyncResponse]:
"""
异步获取订阅分享数据。
"""
return await cls._async_get(cls._server_url(cls._SUBSCRIBE_SHARES_PATH), params=params, timeout=15)
@classmethod
def subscribe_share_statistics(cls) -> Optional[Response]:
"""
获取订阅分享统计数据。
"""
return cls._get(cls._server_url(cls._SUBSCRIBE_SHARE_STATISTICS_PATH), timeout=15)
@classmethod
async def async_subscribe_share_statistics(cls) -> Optional[AsyncResponse]:
"""
异步获取订阅分享统计数据。
"""
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: object,
) -> 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
def _build_subscribe_query_plan(
cls,
**kwargs: Any,
) -> Optional[Dict[str, Any]]:
"""
在订阅统计已启用时生成统计或分享列表查询计划。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return None
return cls._build_subscribe_query_params(**kwargs)
@classmethod
def _build_subscribe_statistic_plan(
cls,
item: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""
在订阅统计已启用且载荷有效时生成新增或完成上报计划。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return None
return cls._build_subscribe_statistic_payload(item)
@staticmethod
def _feature_disabled_result(
setting_key: str,
message: str,
) -> Optional[Tuple[bool, str]]:
"""
将分享功能开关统一映射为可直接返回的禁用结果。
"""
if not get_runtime_setting(setting_key):
return False, message
return None
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, 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[str, Any]]:
"""
获取订阅统计数据。
"""
params = cls._build_subscribe_query_plan(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
stype=stype,
)
if params is None:
return []
return cls._handle_list_response(cls.subscribe_statistic(params))
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, 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[str, Any]]:
"""
异步获取订阅统计数据。
"""
params = cls._build_subscribe_query_plan(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
stype=stype,
)
if params is None:
return []
return cls._handle_list_response(await cls.async_subscribe_statistic(params))
@classmethod
def sub_reg(cls, sub: dict[str, Any]) -> bool:
"""
新增订阅统计。
"""
payload = cls._build_subscribe_statistic_plan(sub)
if not payload:
return False
return cls._response_succeeded(cls.subscribe_add(payload))
@classmethod
async def async_sub_reg(cls, sub: dict[str, Any]) -> bool:
"""
异步新增订阅统计。
"""
payload = cls._build_subscribe_statistic_plan(sub)
if not payload:
return False
return cls._response_succeeded(await cls.async_subscribe_add(payload))
@classmethod
def sub_reg_durable(cls, sub: dict[str, Any]) -> bool:
"""
同步上报新增统计;明确禁用时视为无需投递。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return True
return cls.sub_reg(sub)
@classmethod
async def async_sub_reg_durable(cls, sub: dict[str, Any]) -> bool:
"""
异步上报新增统计;明确禁用时视为无需投递。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return True
return await cls.async_sub_reg(sub)
@classmethod
def sub_done(cls, sub: dict[str, Any]) -> bool:
"""
完成订阅统计。
"""
payload = cls._build_subscribe_statistic_plan(sub)
if not payload:
return False
return cls._response_succeeded(cls.subscribe_done(payload))
@classmethod
async def async_sub_done(cls, sub: dict[str, Any]) -> bool:
"""
异步完成订阅统计,并仅在服务端确认成功时返回 True。
"""
payload = cls._build_subscribe_statistic_plan(sub)
if not payload:
return False
return cls._response_succeeded(await cls.async_subscribe_done(payload))
@classmethod
def sub_done_durable(cls, sub: dict[str, Any]) -> bool:
"""
同步上报完成统计;明确禁用时视为无需投递。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return True
return cls.sub_done(sub)
@classmethod
async def async_sub_done_durable(cls, sub: dict[str, Any]) -> bool:
"""
异步上报完成统计;明确禁用时视为无需投递。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return True
return await cls.async_sub_done(sub)
@staticmethod
def _submit_statistic_report(
coroutine: Coroutine[Any, Any, Any],
*,
submit: Callable[[Coroutine[Any, Any, Any]], object],
action: str,
) -> bool:
"""
提交兼容统计上报,并在宿主不再接收任务时关闭未执行协程。
"""
try:
submit(coroutine)
return True
except Exception as err:
coroutine.close()
logger.warning(f"调度{action}失败:{err}")
return False
@classmethod
def sub_reg_async(cls, sub: dict[str, Any]) -> bool:
"""
兼容旧同步入口,在宿主任务登记器中提交新增订阅统计。
"""
return cls._submit_statistic_report(
asyncio.to_thread(cls.sub_reg, sub),
submit=lambda coroutine: get_task_registry().submit_threadsafe(
coroutine,
loop=main_loop_registry.require(),
owner="compat.server.subscribe_added_report",
cancel_on_shutdown=False,
),
action="新增订阅统计上报",
)
@classmethod
def sub_done_async(cls, sub: dict[str, Any]) -> bool:
"""
兼容旧同步入口,在宿主任务登记器中提交订阅完成统计。
"""
return cls._submit_statistic_report(
asyncio.to_thread(cls.sub_done, sub),
submit=lambda coroutine: get_task_registry().submit_threadsafe(
coroutine,
loop=main_loop_registry.require(),
owner="compat.server.subscribe_done_report",
cancel_on_shutdown=False,
),
action="订阅完成统计上报",
)
@classmethod
def sub_report(cls) -> bool:
"""
上报存量订阅统计。
"""
return cls._report_service().report_subscribes(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
)
@classmethod
async def async_sub_report(cls) -> bool:
"""
异步上报存量订阅统计。
"""
return await cls._report_service().async_report_subscribes(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
)
@classmethod
def sub_share(
cls,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
分享订阅。
"""
return cls._sharing_service().share_subscribe(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
subscribe_id=subscribe_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
async def async_sub_share(
cls,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
异步分享订阅。
"""
return await cls._sharing_service().async_share_subscribe(
enabled=get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'),
subscribe_id=subscribe_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
def _build_subscribe_statistic_payload(
cls, item: Optional[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""
构造中心服务订阅统计载荷,只保留统一身份和公开统计字段。
"""
return cls._report_service().build_subscribe_payload(item)
@classmethod
def _build_subscribe_share_payload(
cls, item: Optional[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""
构造中心服务订阅分享载荷,隔离本地运行字段和旧专用 ID。
"""
return cls._sharing_service().build_subscribe_payload(item)
@classmethod
def share_delete(cls, share_id: int) -> Tuple[bool, str]:
"""
删除订阅分享。
"""
disabled = cls._feature_disabled_result(
'SUBSCRIBE_STATISTIC_SHARE',
"当前没有开启订阅数据共享功能",
)
if disabled:
return disabled
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]:
"""
异步删除订阅分享。
"""
disabled = cls._feature_disabled_result(
'SUBSCRIBE_STATISTIC_SHARE',
"当前没有开启订阅数据共享功能",
)
if disabled:
return disabled
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]:
"""
复用订阅分享。
"""
disabled = cls._feature_disabled_result(
'SUBSCRIBE_STATISTIC_SHARE',
"当前没有开启订阅数据共享功能",
)
if disabled:
return disabled
return cls._handle_response(cls.subscribe_fork(share_id))
@classmethod
async def async_sub_fork(cls, share_id: int) -> Tuple[bool, str]:
"""
异步复用订阅分享。
"""
disabled = cls._feature_disabled_result(
'SUBSCRIBE_STATISTIC_SHARE',
"当前没有开启订阅数据共享功能",
)
if disabled:
return disabled
return cls._handle_response(await cls.async_subscribe_fork(share_id))
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, 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[str, Any]]:
"""
获取订阅分享数据。
"""
params = cls._build_subscribe_query_plan(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
name=name,
)
if params is None:
return []
return cls._handle_list_response(cls.subscribe_shares(params))
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, 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[str, Any]]:
"""
异步获取订阅分享数据。
"""
params = cls._build_subscribe_query_plan(
page=page,
count=count,
genre_id=genre_id,
min_rating=min_rating,
max_rating=max_rating,
sort_type=sort_type,
name=name,
)
if params is None:
return []
return cls._handle_list_response(await cls.async_subscribe_shares(params))
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
def get_subscribe_share_statistics(cls) -> List[dict[str, Any]]:
"""
获取订阅分享统计数据。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return []
return cls._handle_list_response(cls.subscribe_share_statistics())
@classmethod
@_cached_server_result(region="subscribe_share", maxsize=32, ttl=1800, skip_empty=True)
async def async_get_subscribe_share_statistics(cls) -> List[dict[str, Any]]:
"""
异步获取订阅分享统计数据。
"""
if not get_runtime_setting('SUBSCRIBE_STATISTIC_SHARE'):
return []
return cls._handle_list_response(await cls.async_subscribe_share_statistics())
@classmethod
def workflow_share(cls, payload: Dict[str, Any]) -> Optional[Response]:
"""
分享工作流数据。
"""
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]) -> Optional[AsyncResponse]:
"""
异步分享工作流数据。
"""
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) -> Optional[Response]:
"""
删除工作流分享数据。
"""
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) -> Optional[AsyncResponse]:
"""
异步删除工作流分享数据。
"""
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) -> Optional[Response]:
"""
复用工作流分享数据。
"""
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) -> Optional[AsyncResponse]:
"""
异步复用工作流分享数据。
"""
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]) -> Optional[Response]:
"""
获取工作流分享数据。
"""
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]) -> Optional[AsyncResponse]:
"""
异步获取工作流分享数据。
"""
return await cls._async_get(cls._server_url(cls._WORKFLOW_SHARES_PATH), params=params, timeout=15)
@classmethod
def _prepare_workflow_data(cls, workflow: WorkflowSnapshot) -> dict[str, Any]:
"""
准备工作流分享数据。
"""
return cls._sharing_service().prepare_workflow(workflow)
@staticmethod
def _build_workflow_query_plan(
name: Optional[str],
page: Optional[int],
count: Optional[int],
) -> Optional[Dict[str, Any]]:
"""
在工作流分享已启用时生成列表查询计划。
"""
if not get_runtime_setting('WORKFLOW_STATISTIC_SHARE'):
return None
return {"name": name, "page": page, "count": count}
@classmethod
def workflow_share_by_id(
cls,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
分享工作流。
"""
return cls._sharing_service().share_workflow(
enabled=get_runtime_setting('WORKFLOW_STATISTIC_SHARE'),
workflow_id=workflow_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
async def async_workflow_share_by_id(
cls,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> Tuple[bool, str]:
"""
异步分享工作流。
"""
return await cls._sharing_service().async_share_workflow(
enabled=get_runtime_setting('WORKFLOW_STATISTIC_SHARE'),
workflow_id=workflow_id,
share_title=share_title,
share_comment=share_comment,
share_user=share_user,
)
@classmethod
def workflow_share_delete_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
删除工作流分享。
"""
disabled = cls._feature_disabled_result(
'WORKFLOW_STATISTIC_SHARE',
"当前没有开启工作流数据共享功能",
)
if disabled:
return disabled
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]:
"""
异步删除工作流分享。
"""
disabled = cls._feature_disabled_result(
'WORKFLOW_STATISTIC_SHARE',
"当前没有开启工作流数据共享功能",
)
if disabled:
return disabled
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]:
"""
复用工作流分享。
"""
disabled = cls._feature_disabled_result(
'WORKFLOW_STATISTIC_SHARE',
"当前没有开启工作流数据共享功能",
)
if disabled:
return disabled
return cls._handle_response(cls.workflow_fork(share_id))
@classmethod
async def async_workflow_fork_by_id(cls, share_id: int) -> Tuple[bool, str]:
"""
异步复用工作流分享。
"""
disabled = cls._feature_disabled_result(
'WORKFLOW_STATISTIC_SHARE',
"当前没有开启工作流数据共享功能",
)
if disabled:
return disabled
return cls._handle_response(await cls.async_workflow_fork(share_id))
@classmethod
@_cached_server_result(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[str, Any]]:
"""
获取工作流分享数据。
"""
params = cls._build_workflow_query_plan(name, page, count)
if params is None:
return []
return cls._handle_list_response(cls.workflow_shares(params))
@classmethod
@_cached_server_result(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[str, Any]]:
"""
异步获取工作流分享数据。
"""
params = cls._build_workflow_query_plan(name, page, count)
if params is None:
return []
return cls._handle_list_response(await cls.async_workflow_shares(params))
@classmethod
def _validate_workflow(cls, workflow: Optional[WorkflowSnapshot]) -> Tuple[bool, str]:
"""
验证工作流是否可以分享。
"""
return cls._sharing_service().validate_workflow(workflow)
@classmethod
def recognize_share_url(cls) -> Optional[str]:
"""
获取共享识别服务端地址。
"""
custom_api = (get_runtime_setting('MEDIA_RECOGNIZE_SHARE_API') or "").strip()
if custom_api:
return custom_api.rstrip("/")
server_host = (get_runtime_setting('MP_SERVER_HOST') or "").strip().rstrip("/")
if not server_host:
return None
return f"{server_host}{cls._RECOGNIZE_SHARE_PATH}"
@classmethod
def _build_recognize_transport_plan(
cls,
data: Dict[str, Any],
) -> Optional[Tuple[str, Dict[str, Any]]]:
"""
把共享识别地址与已验证请求数据组合成纯传输计划。
"""
api_url = cls.recognize_share_url()
if not api_url:
return None
return api_url, data
@classmethod
def _build_recognize_query_plan(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[Tuple[Dict[str, Any], Optional[str]]]:
"""
在共享识别已启用且关键字有效时生成查询计划。
"""
if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'):
return None
params = cls._build_recognize_query_params(
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
music_type=music_type,
)
if not params:
return None
return params, params.get("keyword")
@classmethod
def _build_recognize_report_plan(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> Optional[Dict[str, Any]]:
"""
在共享识别已启用且统一媒体身份完整时生成上报载荷。
"""
if not get_runtime_setting('MEDIA_RECOGNIZE_SHARE'):
return None
return cls._build_recognize_report_payload(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
@classmethod
def recognize_query(cls, params: Dict[str, Any]) -> Optional[Response]:
"""
查询共享识别结果。
"""
plan = cls._build_recognize_transport_plan(params)
if not plan:
return None
api_url, request_data = plan
return cls._get(api_url, params=request_data, timeout=5)
@classmethod
async def async_recognize_query(cls, params: Dict[str, Any]) -> Optional[AsyncResponse]:
"""
异步查询共享识别结果。
"""
plan = cls._build_recognize_transport_plan(params)
if not plan:
return None
api_url, request_data = plan
return await cls._async_get(api_url, params=request_data, timeout=5)
@classmethod
def recognize_report(cls, payload: Dict[str, Any]) -> Optional[Response]:
"""
上报共享识别结果。
"""
plan = cls._build_recognize_transport_plan(payload)
if not plan:
return None
api_url, request_data = plan
return cls._post_json(api_url, request_data, timeout=5)
@classmethod
async def async_recognize_report(cls, payload: Dict[str, Any]) -> Optional[AsyncResponse]:
"""
异步上报共享识别结果。
"""
plan = cls._build_recognize_transport_plan(payload)
if not plan:
return None
api_url, request_data = plan
return await cls._async_post_json(api_url, request_data, timeout=5)
@classmethod
def query_recognize_share(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
查询共享识别结果。
"""
plan = cls._build_recognize_query_plan(
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
music_type=music_type,
)
if not plan:
return None
params, keyword = plan
response = cls.recognize_query(params)
return cls._parse_recognize_response(response, keyword)
@classmethod
async def async_query_recognize_share(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
异步查询共享识别结果。
"""
plan = cls._build_recognize_query_plan(
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
music_type=music_type,
)
if not plan:
return None
params, keyword = plan
response = await cls.async_recognize_query(params)
return cls._parse_recognize_response(response, keyword)
@classmethod
def report_recognize_share(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
上报共享识别结果,电影、电视剧、音乐共用。
"""
payload = cls._build_recognize_report_plan(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
if payload is None:
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[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> bool:
"""
异步上报共享识别结果,电影、电视剧、音乐共用。
"""
payload = cls._build_recognize_report_plan(
meta=meta,
mediainfo=mediainfo,
keyword_meta=keyword_meta,
)
if payload is None:
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[str, Any]]) -> Optional[dict[str, Any]]:
"""
将服务端返回的共享识别结果转成本地统一识别参数。
"""
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
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
return {
"mtype": mtype,
"media_source": media_source,
"media_id": media_id,
"music_type": normalize_music_type(item.get("music_type"))
or (MUSIC_ENTITY_RECORDING if mtype == MediaType.MUSIC else None),
"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", "music"}:
return normalized
if isinstance(media_type, str):
if media_type == MediaType.MOVIE.value:
return "movie"
if media_type == MediaType.TV.value:
return "tv"
if media_type == MediaType.MUSIC.value:
return "music"
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[Union[MediaInfo, MusicInfo]] = None,
) -> Optional[str]:
"""
提取媒体类型。
"""
media_type = cls._normalize_media_type(mtype)
if media_type:
return media_type
shareable_types = {MediaType.MOVIE, MediaType.TV, MediaType.MUSIC}
if mediainfo and mediainfo.type in shareable_types:
return mediainfo.type.to_agent()
if meta and meta.type in shareable_types:
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[Union[MediaInfo, MusicInfo]] = 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[Union[MediaInfo, MusicInfo]] = 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,
music_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
组装共享识别查询参数。
"""
keyword = cls._extract_keyword(keyword_meta or meta)
if not keyword:
return None
media_type = cls._extract_media_type(meta=meta, mtype=mtype)
params: dict[str, str | int] = {"keyword": keyword}
if media_type:
params["type"] = media_type
if media_type == "music":
params["music_type"] = (
normalize_music_type(music_type)
or MUSIC_ENTITY_RECORDING
)
if year := cls._extract_year(meta=meta):
params["year"] = year
season = cls._extract_season(media_type=media_type, meta=meta)
if season is not None:
params["season"] = season
return params
@classmethod
def _build_recognize_report_payload(
cls,
meta: Optional[MetaBase],
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
keyword_meta: Optional[MetaBase] = None,
) -> Optional[dict[str, Any]]:
"""
组装共享识别上报载荷,电影、电视剧、音乐共用同一结构。
"""
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
media_source, media_id = resolve_media_identity(media=mediainfo)
if not media_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,
),
"media_source": str(media_source),
"media_id": media_id,
"music_type": (
normalize_music_type(getattr(mediainfo, "music_type", None))
if media_type == "music" else None
),
}
@classmethod
def _parse_recognize_response(cls, response: Response | AsyncResponse | None, keyword: Optional[str]) -> Optional[dict[str, Any]]:
"""
解析共享识别查询响应。
"""
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: Response | AsyncResponse | None) -> 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_mapping(response)
except (json.JSONDecodeError, ValueError) as err:
logger.warn(f"解析共享媒体识别上报响应失败:{err}")
return False
return bool(result.get("code") == 0)
@staticmethod
def _parse_response_item(data: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
"""
解析服务端返回的共享识别数据。
"""
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: Response | AsyncResponse) -> 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]]:
"""
构建批量插件安装统计载荷。
"""
return cls._report_service().build_plugin_payload(items)
@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:
"""
清理订阅共享相关缓存。
"""
_clear_server_cache(cls.get_subscribe_shares)
_clear_server_cache(cls.get_subscribe_statistic)
_clear_server_cache(cls.get_subscribe_share_statistics)
@classmethod
def _clear_workflow_share_cache(cls) -> None:
"""
清理工作流共享相关缓存。
"""
_clear_server_cache(cls.get_workflow_shares)
@classmethod
def _has_header(cls, headers: dict[str, Any], 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"{get_runtime_setting('MP_SERVER_HOST').rstrip('/')}{path}"
@classmethod
def _get(
cls,
url: str,
params: Optional[dict[str, Any]] = None,
timeout: int = 10,
include_user_uid: bool = True,
) -> Optional[Response]:
"""
发送服务端 GET 请求,默认携带安装用户 ID。
"""
return RequestUtils(
proxies=get_runtime_setting('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[str, Any]] = None,
timeout: int = 10,
include_user_uid: bool = True,
) -> Optional[AsyncResponse]:
"""
异步发送服务端 GET 请求,默认携带安装用户 ID。
"""
return await AsyncRequestUtils(
http2=False,
proxies=get_runtime_setting('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) -> Optional[Response]:
"""
发送携带安装用户 ID 的服务端 JSON POST 请求。
"""
return RequestUtils(
proxies=get_runtime_setting('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) -> Optional[AsyncResponse]:
"""
异步发送携带安装用户 ID 的服务端 JSON POST 请求。
"""
return await AsyncRequestUtils(
http2=False,
proxies=get_runtime_setting('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[str, Any]] = None, timeout: int = 10) -> Optional[Response]:
"""
发送携带安装用户 ID 的服务端 DELETE 请求。
"""
client = RequestUtils(
proxies=get_runtime_setting('PROXY'),
timeout=timeout,
headers=cls.build_headers(url),
)
if params is None:
return client.delete_res(url)
return client.delete_res(url, params=params)
@classmethod
async def _async_delete(cls, url: str, params: Optional[dict[str, Any]] = None, timeout: int = 10) -> Optional[AsyncResponse]:
"""
异步发送携带安装用户 ID 的服务端 DELETE 请求。
"""
client = AsyncRequestUtils(
http2=False,
proxies=get_runtime_setting('PROXY'),
timeout=timeout,
headers=cls.build_headers(url),
)
if params is None:
return await client.delete_res(url)
return await client.delete_res(url, params=params)