mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
refactor: centralize MoviePilot server helper
This commit is contained in:
@@ -49,9 +49,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
|
||||
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
|
||||
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/{{pid}}"
|
||||
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
|
||||
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
|
||||
# 串行化运行期依赖安装,避免多个 pip 子进程和导入缓存刷新互相踩踏。
|
||||
_pip_install_lock = threading.Lock()
|
||||
# 这些包一旦被插件覆盖,最容易直接拖垮主程序启动,因此冲突提示需要单独高亮。
|
||||
@@ -72,10 +69,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if settings.PLUGIN_STATISTIC_SHARE:
|
||||
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
|
||||
if self.install_report():
|
||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||
|
||||
@staticmethod
|
||||
def is_local_repo_url(repo_url: Optional[str]) -> bool:
|
||||
@@ -147,25 +140,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def sanitize_repo_url_for_statistic(repo_url: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
统计上报前脱敏 repo_url,避免泄露本地仓库绝对路径
|
||||
"""
|
||||
if not repo_url:
|
||||
return repo_url
|
||||
if not PluginHelper.is_local_repo_url(repo_url):
|
||||
return repo_url
|
||||
|
||||
pid = PluginHelper.parse_local_repo_url(repo_url)
|
||||
if not pid:
|
||||
return LOCAL_REPO_PREFIX.rstrip("/")
|
||||
|
||||
return PluginHelper.make_local_repo_url(
|
||||
pid=pid,
|
||||
package_version=PluginHelper.parse_local_repo_package_version(repo_url)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_current_system_version() -> Optional[Version]:
|
||||
"""
|
||||
@@ -505,65 +479,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return None, None
|
||||
return user, repo
|
||||
|
||||
@cached(maxsize=1, ttl=1800)
|
||||
def get_statistic(self) -> Dict:
|
||||
"""
|
||||
获取插件安装统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return {}
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)
|
||||
if res is not None and res.status_code == 200:
|
||||
return res.json()
|
||||
return {}
|
||||
|
||||
def install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool:
|
||||
"""
|
||||
安装插件统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
if not pid:
|
||||
return False
|
||||
install_reg_url = self._install_reg.format(pid=pid)
|
||||
res = RequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5
|
||||
).post(install_reg_url, json={
|
||||
"plugin_id": pid,
|
||||
"repo_url": self.sanitize_repo_url_for_statistic(repo_url)
|
||||
})
|
||||
if res is not None and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
|
||||
"""
|
||||
上报存量插件安装统计(批量)。支持上送 repo_url。
|
||||
:param items: 可选,形如 [(plugin_id, repo_url), ...];不传则回落到历史配置,仅上送 plugin_id。
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
payload_plugins = []
|
||||
if items:
|
||||
for pid, repo_url in items:
|
||||
if pid:
|
||||
payload_plugins.append({
|
||||
"plugin_id": pid,
|
||||
"repo_url": self.sanitize_repo_url_for_statistic(repo_url)
|
||||
})
|
||||
else:
|
||||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||
if not plugins:
|
||||
return False
|
||||
payload_plugins = [{"plugin_id": plugin, "repo_url": None} for plugin in plugins]
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5).post(self._install_report,
|
||||
json={"plugins": payload_plugins})
|
||||
return bool(res is not None and res.status_code == 200)
|
||||
|
||||
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -1595,7 +1510,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
self.install_reg(pid, repo_url)
|
||||
self.refresh_persistent_plugin_backup(pid)
|
||||
return True, ""
|
||||
|
||||
@@ -1961,64 +1875,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return None
|
||||
return self.__parse_plugin_index_response(res.text)
|
||||
|
||||
async def async_get_statistic(self) -> Dict:
|
||||
"""
|
||||
异步获取插件安装统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return {}
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)
|
||||
if res is not None and res.status_code == 200:
|
||||
return res.json()
|
||||
return {}
|
||||
|
||||
async def async_install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool:
|
||||
"""
|
||||
异步安装插件统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
if not pid:
|
||||
return False
|
||||
install_reg_url = self._install_reg.format(pid=pid)
|
||||
res = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5
|
||||
).post(install_reg_url, json={
|
||||
"plugin_id": pid,
|
||||
"repo_url": self.sanitize_repo_url_for_statistic(repo_url)
|
||||
})
|
||||
if res is not None and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:
|
||||
"""
|
||||
异步上报存量插件安装统计(批量)。支持上送 repo_url。
|
||||
:param items: 可选,形如 [(plugin_id, repo_url), ...];不传则回落到历史配置,仅上送 plugin_id。
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
payload_plugins = []
|
||||
if items:
|
||||
for pid, repo_url in items:
|
||||
if pid:
|
||||
payload_plugins.append({
|
||||
"plugin_id": pid,
|
||||
"repo_url": self.sanitize_repo_url_for_statistic(repo_url)
|
||||
})
|
||||
else:
|
||||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||
if not plugins:
|
||||
return False
|
||||
payload_plugins = [{"plugin_id": plugin, "repo_url": None} for plugin in plugins]
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5).post(self._install_report,
|
||||
json={"plugins": payload_plugins})
|
||||
return bool(res is not None and res.status_code == 200)
|
||||
|
||||
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
|
||||
Tuple[Optional[list], Optional[str]]:
|
||||
"""
|
||||
@@ -2503,7 +2359,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
await self.async_install_reg(pid, repo_url)
|
||||
await asyncio.to_thread(self.refresh_persistent_plugin_backup, pid)
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
|
||||
|
||||
class MediaRecognizeShareHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
共享媒体识别帮助类
|
||||
"""
|
||||
|
||||
_default_path = "/recognize/share"
|
||||
|
||||
@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_api_url(cls) -> Optional[str]:
|
||||
"""
|
||||
获取共享识别API地址
|
||||
"""
|
||||
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._default_path}"
|
||||
|
||||
@classmethod
|
||||
def _build_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_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,
|
||||
}
|
||||
|
||||
@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 ""
|
||||
|
||||
@staticmethod
|
||||
def _is_enabled() -> bool:
|
||||
"""
|
||||
是否启用共享识别
|
||||
"""
|
||||
return bool(settings.MEDIA_RECOGNIZE_SHARE)
|
||||
|
||||
def query(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mtype: Optional[MediaType] = None,
|
||||
keyword_meta: Optional[MetaBase] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
查询共享识别结果
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return None
|
||||
|
||||
api_url = self._build_api_url()
|
||||
params = self._build_query_params(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
keyword_meta=keyword_meta,
|
||||
)
|
||||
if not api_url or not params:
|
||||
return None
|
||||
|
||||
response = RequestUtils(proxies=settings.PROXY or {}, timeout=5).get_res(
|
||||
api_url,
|
||||
params=params,
|
||||
)
|
||||
if not response or response.status_code != 200:
|
||||
if response is not None:
|
||||
logger.warn(
|
||||
f"查询共享媒体识别失败:status={response.status_code} "
|
||||
f"message={self._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 = self._parse_response_item(payload)
|
||||
if item:
|
||||
logger.info(f"共享媒体识别命中:{params.get('keyword')} - {item}")
|
||||
return item
|
||||
|
||||
async def async_query(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mtype: Optional[MediaType] = None,
|
||||
keyword_meta: Optional[MetaBase] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
异步查询共享识别结果
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return None
|
||||
|
||||
api_url = self._build_api_url()
|
||||
params = self._build_query_params(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
keyword_meta=keyword_meta,
|
||||
)
|
||||
if not api_url or not params:
|
||||
return None
|
||||
|
||||
response = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY or {},
|
||||
timeout=5,
|
||||
).get_res(api_url, params=params)
|
||||
if not response or response.status_code != 200:
|
||||
if response is not None:
|
||||
logger.warn(
|
||||
f"异步查询共享媒体识别失败:status={response.status_code} "
|
||||
f"message={self._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 = self._parse_response_item(payload)
|
||||
if item:
|
||||
logger.info(f"共享媒体识别命中:{params.get('keyword')} - {item}")
|
||||
return item
|
||||
|
||||
def report(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
keyword_meta: Optional[MetaBase] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
上报共享识别结果
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
api_url = self._build_api_url()
|
||||
payload = self._build_report_payload(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
keyword_meta=keyword_meta,
|
||||
)
|
||||
if not api_url or not payload:
|
||||
return False
|
||||
|
||||
response = RequestUtils(
|
||||
proxies=settings.PROXY or {},
|
||||
timeout=5,
|
||||
content_type="application/json",
|
||||
).post_res(api_url, json=payload)
|
||||
if not response or response.status_code != 200:
|
||||
if response is not None:
|
||||
logger.warn(
|
||||
f"上报共享媒体识别失败:status={response.status_code} "
|
||||
f"message={self._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
|
||||
|
||||
async def async_report(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
keyword_meta: Optional[MetaBase] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
异步上报共享识别结果
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
api_url = self._build_api_url()
|
||||
payload = self._build_report_payload(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
keyword_meta=keyword_meta,
|
||||
)
|
||||
if not api_url or not payload:
|
||||
return False
|
||||
|
||||
response = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY or {},
|
||||
timeout=5,
|
||||
content_type="application/json",
|
||||
).post_res(api_url, json=payload)
|
||||
if not response or response.status_code != 200:
|
||||
if response is not None:
|
||||
logger.warn(
|
||||
f"异步上报共享媒体识别失败:status={response.status_code} "
|
||||
f"message={self._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
|
||||
|
||||
@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"),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,506 +0,0 @@
|
||||
from threading import Thread
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SubscribeHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
订阅数据统计/订阅分享等
|
||||
"""
|
||||
|
||||
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
|
||||
|
||||
_sub_done = f"{settings.MP_SERVER_HOST}/subscribe/done"
|
||||
|
||||
_sub_report = f"{settings.MP_SERVER_HOST}/subscribe/report"
|
||||
|
||||
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
|
||||
|
||||
_sub_share = f"{settings.MP_SERVER_HOST}/subscribe/share"
|
||||
|
||||
_sub_shares = f"{settings.MP_SERVER_HOST}/subscribe/shares"
|
||||
|
||||
_sub_share_statistic = f"{settings.MP_SERVER_HOST}/subscribe/share/statistics"
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache_region = "subscribe_share"
|
||||
|
||||
_github_user = None
|
||||
|
||||
_share_user_id = None
|
||||
|
||||
_admin_users = [
|
||||
"jxxghp",
|
||||
"thsrite",
|
||||
"InfinityPacer",
|
||||
"DDSRem",
|
||||
"Aqr-K",
|
||||
"Putarku",
|
||||
"4Nest",
|
||||
"xyswordzoro",
|
||||
"wikrin"
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
systemconfig = SystemConfigOper()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
if not systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||
if self.sub_report():
|
||||
systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||
self.get_user_uuid()
|
||||
self.get_github_user()
|
||||
|
||||
@staticmethod
|
||||
def _check_subscribe_share_enabled() -> Tuple[bool, str]:
|
||||
"""
|
||||
检查订阅分享功能是否开启
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_subscribe(subscribe) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证订阅是否存在
|
||||
"""
|
||||
if not subscribe:
|
||||
return False, "订阅不存在"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _prepare_subscribe_data(subscribe) -> dict:
|
||||
"""
|
||||
准备订阅分享数据
|
||||
"""
|
||||
subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict.pop("id", None)
|
||||
return subscribe_dict
|
||||
|
||||
def _build_share_payload(self, share_title: str, share_comment: str,
|
||||
share_user: str, subscribe_dict: dict) -> dict:
|
||||
"""
|
||||
构建分享请求载荷
|
||||
"""
|
||||
return {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._share_user_id,
|
||||
**subscribe_dict
|
||||
}
|
||||
|
||||
def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理HTTP响应
|
||||
"""
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
|
||||
# 检查响应状态
|
||||
if res.status_code == 200:
|
||||
# 清除缓存
|
||||
if clear_cache:
|
||||
self.get_shares.cache_clear()
|
||||
self.get_statistic.cache_clear()
|
||||
self.get_share_statistics.cache_clear()
|
||||
self.async_get_shares.cache_clear()
|
||||
self.async_get_statistic.cache_clear()
|
||||
self.async_get_share_statistics.cache_clear()
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@staticmethod
|
||||
def _handle_list_response(res) -> List[dict]:
|
||||
"""
|
||||
处理返回List的HTTP响应
|
||||
"""
|
||||
if res is not None and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)
|
||||
def get_statistic(self, 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]:
|
||||
"""
|
||||
获取订阅统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
params = {
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
}
|
||||
|
||||
# 添加可选参数
|
||||
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
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)
|
||||
async def async_get_statistic(self, 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]:
|
||||
"""
|
||||
异步获取订阅统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
params = {
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
}
|
||||
|
||||
# 添加可选参数
|
||||
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
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def sub_reg(self, sub: dict) -> bool:
|
||||
"""
|
||||
新增订阅统计
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res is not None and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_sub_reg(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步新增订阅统计
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res is not None and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sub_done(self, sub: dict) -> bool:
|
||||
"""
|
||||
完成订阅统计
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_done, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sub_reg_async(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步新增订阅统计
|
||||
"""
|
||||
# 开新线程处理
|
||||
Thread(target=self.sub_reg, args=(sub,)).start()
|
||||
return True
|
||||
|
||||
def sub_done_async(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步完成订阅统计
|
||||
"""
|
||||
# 开新线程处理
|
||||
Thread(target=self.sub_done, args=(sub,)).start()
|
||||
return True
|
||||
|
||||
def sub_report(self) -> bool:
|
||||
"""
|
||||
上报存量订阅统计
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
return True
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_report,
|
||||
json={
|
||||
"subscribes": [
|
||||
sub.to_dict() for sub in subscribes
|
||||
]
|
||||
})
|
||||
return bool(res is not None and res.status_code == 200)
|
||||
|
||||
def sub_share(self, subscribe_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
分享订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取订阅信息
|
||||
subscribe = SubscribeOper().get(subscribe_id)
|
||||
|
||||
# 验证订阅
|
||||
valid, message = self._validate_subscribe(subscribe)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
subscribe_dict = self._prepare_subscribe_data(subscribe)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_sub_share(self, subscribe_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步分享订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取订阅信息
|
||||
subscribe = await SubscribeOper().async_get(subscribe_id)
|
||||
|
||||
# 验证订阅
|
||||
valid, message = self._validate_subscribe(subscribe)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
subscribe_dict = self._prepare_subscribe_data(subscribe)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||
timeout=10).post(self._sub_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
timeout=5).delete_res(f"{self._sub_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY,
|
||||
timeout=5).delete_res(f"{self._sub_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def sub_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
复用分享的订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
async def async_sub_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步复用分享的订阅
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._sub_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_shares(self, 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]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
params = {
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
}
|
||||
|
||||
# 添加可选参数
|
||||
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
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
async def async_get_shares(self, 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]:
|
||||
"""
|
||||
异步获取订阅分享数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
params = {
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
}
|
||||
|
||||
# 添加可选参数
|
||||
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
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_share_statistics(self) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
async def async_get_share_statistics(self) -> List[dict]:
|
||||
"""
|
||||
异步获取订阅分享统计数据
|
||||
"""
|
||||
enabled, _ = self._check_subscribe_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)
|
||||
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def get_user_uuid(self) -> str:
|
||||
"""
|
||||
获取用户uuid
|
||||
"""
|
||||
if not self._share_user_id:
|
||||
self._share_user_id = SystemUtils.generate_user_unique_id()
|
||||
logger.info(f"当前用户UUID: {self._share_user_id}")
|
||||
return self._share_user_id
|
||||
|
||||
def get_github_user(self) -> str:
|
||||
"""
|
||||
获取github用户
|
||||
"""
|
||||
if self._github_user is None and settings.GITHUB_HEADERS:
|
||||
res = RequestUtils(headers=settings.GITHUB_HEADERS,
|
||||
proxies=settings.PROXY,
|
||||
timeout=15).get_res(f"https://api.github.com/user")
|
||||
if res:
|
||||
self._github_user = res.json().get("login")
|
||||
logger.info(f"当前Github用户: {self._github_user}")
|
||||
return self._github_user
|
||||
|
||||
def is_admin_user(self) -> bool:
|
||||
"""
|
||||
判断是否是管理员
|
||||
"""
|
||||
if not self._github_user:
|
||||
return False
|
||||
if self._github_user in self._admin_users:
|
||||
return True
|
||||
return False
|
||||
@@ -1,119 +0,0 @@
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import AsyncRequestUtils, RequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.system import SystemUtils
|
||||
from version import APP_VERSION, FRONTEND_VERSION
|
||||
|
||||
|
||||
class UsageHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
安装版本统计上报
|
||||
"""
|
||||
|
||||
_usage_report = f"{settings.MP_SERVER_HOST}/usage/report"
|
||||
_usage_statistic = f"{settings.MP_SERVER_HOST}/usage/statistic"
|
||||
|
||||
@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
|
||||
|
||||
@staticmethod
|
||||
def build_payload() -> Dict[str, Any]:
|
||||
"""
|
||||
构建安装版本统计上报载荷。
|
||||
"""
|
||||
return {
|
||||
"user_uid": SystemUtils.generate_user_unique_id(),
|
||||
"backend_version": APP_VERSION,
|
||||
"frontend_version": UsageHelper.get_frontend_version(),
|
||||
"version_flag": settings.VERSION_FLAG,
|
||||
"platform": f"{platform.system()} {platform.release()}".strip(),
|
||||
"arch": SystemUtils.cpu_arch(),
|
||||
}
|
||||
|
||||
def report(self) -> bool:
|
||||
"""
|
||||
上报当前安装实例的版本统计。
|
||||
"""
|
||||
if not settings.USAGE_STATISTIC_SHARE:
|
||||
return False
|
||||
payload = self.build_payload()
|
||||
if not payload.get("user_uid"):
|
||||
return False
|
||||
try:
|
||||
res = RequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5,
|
||||
).post(self._usage_report, json=payload)
|
||||
return bool(res is not None and res.status_code == 200)
|
||||
except Exception as err:
|
||||
logger.debug(f"上报安装版本统计失败:{str(err)}")
|
||||
return False
|
||||
|
||||
async def async_report(self) -> bool:
|
||||
"""
|
||||
异步上报当前安装实例的版本统计。
|
||||
"""
|
||||
if not settings.USAGE_STATISTIC_SHARE:
|
||||
return False
|
||||
payload = self.build_payload()
|
||||
if not payload.get("user_uid"):
|
||||
return False
|
||||
try:
|
||||
res = await AsyncRequestUtils(
|
||||
proxies=settings.PROXY,
|
||||
content_type="application/json",
|
||||
timeout=5,
|
||||
).post(self._usage_report, json=payload)
|
||||
return bool(res is not None and res.status_code == 200)
|
||||
except Exception as err:
|
||||
logger.debug(f"异步上报安装版本统计失败:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_statistic(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取安装版本统计报表。
|
||||
"""
|
||||
if not settings.USAGE_STATISTIC_SHARE:
|
||||
return {}
|
||||
try:
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._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 {}
|
||||
|
||||
async def async_get_statistic(self) -> Dict[str, Any]:
|
||||
"""
|
||||
异步获取安装版本统计报表。
|
||||
"""
|
||||
if not settings.USAGE_STATISTIC_SHARE:
|
||||
return {}
|
||||
try:
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._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 {}
|
||||
@@ -1,276 +0,0 @@
|
||||
import json
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.db.models import Workflow
|
||||
from app.db.workflow_oper import WorkflowOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils, AsyncRequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class WorkflowHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
工作流分享等
|
||||
"""
|
||||
|
||||
_workflow_share = f"{settings.MP_SERVER_HOST}/workflow/share"
|
||||
|
||||
_workflow_shares = f"{settings.MP_SERVER_HOST}/workflow/shares"
|
||||
|
||||
_workflow_fork = f"{settings.MP_SERVER_HOST}/workflow/fork/%s"
|
||||
|
||||
_shares_cache_region = "workflow_share"
|
||||
|
||||
_share_user_id = None
|
||||
|
||||
def __init__(self):
|
||||
self.get_user_uuid()
|
||||
|
||||
@staticmethod
|
||||
def _check_workflow_share_enabled() -> Tuple[bool, str]:
|
||||
"""
|
||||
检查工作流分享功能是否开启
|
||||
"""
|
||||
if not settings.WORKFLOW_STATISTIC_SHARE:
|
||||
return False, "当前没有开启工作流数据共享功能"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_workflow(workflow: Workflow) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证工作流是否可以分享
|
||||
"""
|
||||
if not workflow:
|
||||
return False, "工作流不存在"
|
||||
|
||||
if not workflow.actions or not workflow.flows:
|
||||
return False, "请分享有动作和流程的工作流"
|
||||
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _prepare_workflow_data(workflow: 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
|
||||
|
||||
def _build_share_payload(self, share_title: str, share_comment: str,
|
||||
share_user: str, workflow_dict: dict) -> dict:
|
||||
"""
|
||||
构建分享请求载荷
|
||||
"""
|
||||
return {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._share_user_id,
|
||||
**workflow_dict
|
||||
}
|
||||
|
||||
def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理HTTP响应
|
||||
"""
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
|
||||
# 检查响应状态
|
||||
success = True if res.status_code == 200 else False
|
||||
|
||||
if success:
|
||||
# 清除缓存
|
||||
if clear_cache:
|
||||
self.get_shares.cache_clear()
|
||||
self.async_get_shares.cache_clear()
|
||||
return True, ""
|
||||
else:
|
||||
try:
|
||||
error_msg = res.json().get("message", "未知错误")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"工作流响应JSON解析失败: {e}")
|
||||
error_msg = f"响应解析失败: {res.text[:100]}..."
|
||||
return False, error_msg
|
||||
|
||||
@staticmethod
|
||||
def _handle_list_response(res) -> List[dict]:
|
||||
"""
|
||||
处理返回List的HTTP响应
|
||||
"""
|
||||
if res and res.status_code == 200:
|
||||
try:
|
||||
return res.json()
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"工作流列表响应JSON解析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
def workflow_share(self, workflow_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
分享工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取工作流信息
|
||||
workflow = WorkflowOper().get(workflow_id)
|
||||
|
||||
# 验证工作流
|
||||
valid, message = self._validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
workflow_dict = self._prepare_workflow_data(workflow)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = RequestUtils(proxies=settings.PROXY or {},
|
||||
content_type="application/json",
|
||||
timeout=10).post(self._workflow_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_workflow_share(self, workflow_id: int,
|
||||
share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步分享工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
# 获取工作流信息
|
||||
workflow = await WorkflowOper().async_get(workflow_id)
|
||||
|
||||
# 验证工作流
|
||||
valid, message = self._validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
|
||||
# 准备数据
|
||||
workflow_dict = self._prepare_workflow_data(workflow)
|
||||
payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)
|
||||
|
||||
# 发送分享请求
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
content_type="application/json",
|
||||
timeout=10).post(self._workflow_share, json=payload)
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5).delete_res(f"{self._workflow_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步删除分享
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5).delete_res(f"{self._workflow_share}/{share_id}",
|
||||
params={"share_uid": self._share_user_id})
|
||||
|
||||
return self._handle_response(res)
|
||||
|
||||
def workflow_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
复用分享的工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {}, timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._workflow_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
async def async_workflow_fork(self, share_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步复用分享的工作流
|
||||
"""
|
||||
# 检查功能是否开启
|
||||
enabled, message = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return False, message
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {},
|
||||
timeout=5,
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}).get_res(self._workflow_fork % share_id)
|
||||
|
||||
return self._handle_response(res, clear_cache=False)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, skip_empty=True)
|
||||
def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取工作流分享数据
|
||||
"""
|
||||
enabled, _ = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = RequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
return self._handle_list_response(res)
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, skip_empty=True)
|
||||
async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> \
|
||||
List[dict]:
|
||||
"""
|
||||
异步获取工作流分享数据
|
||||
"""
|
||||
enabled, _ = self._check_workflow_share_enabled()
|
||||
if not enabled:
|
||||
return []
|
||||
|
||||
res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={
|
||||
"name": name,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
return self._handle_list_response(res)
|
||||
|
||||
def get_user_uuid(self) -> str:
|
||||
"""
|
||||
获取用户uuid
|
||||
"""
|
||||
if not self._share_user_id:
|
||||
self._share_user_id = SystemUtils.generate_user_unique_id()
|
||||
logger.info(f"当前用户UUID: {self._share_user_id}")
|
||||
return self._share_user_id or ""
|
||||
Reference in New Issue
Block a user