refactor: centralize MoviePilot server helper

This commit is contained in:
jxxghp
2026-05-27 12:56:45 +08:00
parent db3ad91408
commit 0e5c592862
28 changed files with 1927 additions and 1562 deletions
-145
View File
@@ -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, ""
-406
View File
@@ -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"),
}
+1654
View File
File diff suppressed because it is too large Load Diff
-506
View File
@@ -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
-119
View File
@@ -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 {}
-276
View File
@@ -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 ""