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

View File

@@ -8,6 +8,7 @@ from typing import Any, Optional
from app.core.config import settings
from app.core.plugin import PluginManager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.schemas.types import SystemConfigKey
@@ -230,7 +231,7 @@ async def install_plugin_runtime(
refreshed_only = False
if not force and plugin_id in plugin_manager.get_plugin_ids():
refreshed_only = True
await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
message = "插件已存在,已刷新加载"
else:
if not repo_url:
@@ -242,6 +243,7 @@ async def install_plugin_runtime(
)
if not state:
return False, message, False
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.event import eventmanager
from app.db.subscribe_oper import SubscribeOper
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import EventType
@@ -49,7 +49,7 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
SubscribeHelper().sub_done_async(
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)

View File

@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.core.context import MediaInfo
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -77,8 +77,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
subscribe_helper = SubscribeHelper()
subscribes = await subscribe_helper.async_get_statistic(
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
stype=media_type_enum.to_agent(),
page=page,
count=count,

View File

@@ -6,7 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
MAX_PAGE_SIZE = 50
@@ -68,8 +68,7 @@ class QuerySubscribeSharesTool(MoviePilotTool):
# 订阅分享是外部列表型结果,限制单页大小能降低工具上下文占用。
count = min(count, MAX_PAGE_SIZE)
subscribe_helper = SubscribeHelper()
shares = await subscribe_helper.async_get_shares(
shares = await MoviePilotServerHelper.async_get_subscribe_shares(
name=name,
page=page,
count=count,

View File

@@ -21,6 +21,7 @@ from app.db.user_oper import (
get_current_active_superuser_async,
)
from app.factory import app
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -217,7 +218,7 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
插件安装统计
"""
return await PluginHelper().async_get_statistic()
return await MoviePilotServerHelper.async_get_plugin_statistic()
@router.get(
@@ -257,7 +258,7 @@ async def install(
)
if compatible_message:
return schemas.Response(success=False, message=compatible_message)
await plugin_helper.async_install_reg(pid=plugin_id, repo_url=repo_url)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
else:
# 插件不存在或需要强制安装,下载安装并注册插件
if repo_url:
@@ -267,6 +268,7 @@ async def install(
# 安装失败则直接响应
if not state:
return schemas.Response(success=False, message=msg)
await MoviePilotServerHelper.async_install_plugin_reg(plugin_id=plugin_id, repo_url=repo_url)
else:
# repo_url 为空时,也直接响应
return schemas.Response(

View File

@@ -18,7 +18,7 @@ from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user_async
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.scheduler import Scheduler
from app.schemas.types import MediaType, EventType, SystemConfigKey
@@ -491,7 +491,7 @@ async def popular_subscribes(
"""
查询热门订阅
"""
subscribes = await SubscribeHelper().async_get_statistic(
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
stype=stype,
page=page,
count=count,
@@ -574,7 +574,7 @@ async def subscribe_share(
"""
分享订阅
"""
state, errmsg = await SubscribeHelper().async_sub_share(
state, errmsg = await MoviePilotServerHelper.async_sub_share(
subscribe_id=sub.subscribe_id,
share_title=sub.share_title,
share_comment=sub.share_comment,
@@ -590,7 +590,7 @@ async def subscribe_share_delete(
"""
删除分享
"""
state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id)
state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
@@ -611,7 +611,7 @@ async def subscribe_fork(
subscribe_in=schemas.Subscribe(**sub_dict), current_user=current_user
)
if result.success:
await SubscribeHelper().async_sub_fork(share_id=sub.id)
await MoviePilotServerHelper.async_sub_fork(share_id=sub.id)
return result
@@ -673,7 +673,7 @@ async def subscribe_shares(
"""
查询分享的订阅
"""
return await SubscribeHelper().async_get_shares(
return await MoviePilotServerHelper.async_get_subscribe_shares(
name=name,
page=page,
count=count,
@@ -696,7 +696,7 @@ async def subscribe_share_statistics(
查询订阅分享统计
返回每个分享人分享的媒体数量以及总的复用人次
"""
return await SubscribeHelper().async_get_share_statistics()
return await MoviePilotServerHelper.async_get_subscribe_share_statistics()
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
@@ -733,7 +733,7 @@ async def delete_subscribe(
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
# 统计订阅
SubscribeHelper().sub_done_async(
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
)
return schemas.Response(success=True)

View File

@@ -31,11 +31,10 @@ from app.db.user_oper import (
)
from app.helper.image import ImageHelper
from app.helper.message import MessageHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.subscribe import SubscribeHelper
from app.helper.system import SystemHelper
from app.helper.usage import UsageHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas import ConfigChangeEventData
@@ -492,10 +491,10 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
info["LLM_SUPPORT_AUDIO_OUTPUT"] = False
# 追加用户唯一ID和订阅分享管理权限
share_admin = SubscribeHelper().is_admin_user()
share_admin = MoviePilotServerHelper.is_admin_user()
info.update(
{
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
"USER_UNIQUE_ID": MoviePilotServerHelper.get_user_uuid(),
"SUBSCRIBE_SHARE_MANAGE": share_admin,
"WORKFLOW_SHARE_MANAGE": share_admin,
}
@@ -527,7 +526,7 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
"""
查询安装版本统计报表
"""
return schemas.Response(success=True, data=await UsageHelper().async_get_statistic())
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)

View File

@@ -16,7 +16,7 @@ from app.db import get_async_db, get_db
from app.db.models import Workflow
from app.db.systemconfig_oper import SystemConfigOper
from app.db.workflow_oper import WorkflowOper
from app.helper.workflow import WorkflowHelper
from app.helper.server import MoviePilotServerHelper
from app.scheduler import Scheduler
from app.schemas.types import EventType, EVENT_TYPE_NAMES
@@ -100,7 +100,7 @@ async def workflow_share(
success=False, message="请填写工作流ID、分享标题和分享人"
)
state, errmsg = await WorkflowHelper().async_workflow_share(
state, errmsg = await MoviePilotServerHelper.async_workflow_share_by_id(
workflow_id=workflow.id,
share_title=workflow.share_title or "",
share_comment=workflow.share_comment or "",
@@ -116,7 +116,7 @@ async def workflow_share_delete(
"""
删除分享
"""
state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id)
state, errmsg = await MoviePilotServerHelper.async_workflow_share_delete_by_id(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
@@ -174,7 +174,7 @@ async def workflow_fork(
# 更新复用次数
if workflow:
await WorkflowHelper().async_workflow_fork(share_id=workflow.id)
await MoviePilotServerHelper.async_workflow_fork_by_id(share_id=workflow.id)
return schemas.Response(success=True, message="复用成功")
@@ -191,7 +191,7 @@ async def workflow_shares(
"""
查询分享的工作流
"""
return await WorkflowHelper().async_get_shares(name=name, page=page, count=count)
return await MoviePilotServerHelper.async_get_workflow_shares(name=name, page=page, count=count)
@router.post(

View File

@@ -21,8 +21,8 @@ from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.message_oper import MessageOper
from app.db.user_oper import UserOper
from app.helper.recognize import MediaRecognizeShareHelper
from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import (
@@ -591,7 +591,6 @@ class ChainBase(metaclass=ABCMeta):
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
share_helper = MediaRecognizeShareHelper()
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
@@ -605,7 +604,7 @@ class ChainBase(metaclass=ABCMeta):
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
share_helper.report(
MoviePilotServerHelper.report_recognize_share(
meta=meta,
mediainfo=mediainfo,
keyword_meta=share_query_meta,
@@ -616,12 +615,12 @@ class ChainBase(metaclass=ABCMeta):
share_query_meta, tmdbid, doubanid, bangumiid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = share_helper.query(
shared_item = MoviePilotServerHelper.query_recognize_share(
meta=meta,
mtype=mtype,
keyword_meta=share_query_meta,
)
shared_params = share_helper.to_recognize_params(shared_item)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
with fresh(not cache):
mediainfo = self.run_module(
@@ -676,7 +675,6 @@ class ChainBase(metaclass=ABCMeta):
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
share_helper = MediaRecognizeShareHelper()
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
@@ -690,7 +688,7 @@ class ChainBase(metaclass=ABCMeta):
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
await share_helper.async_report(
await MoviePilotServerHelper.async_report_recognize_share(
meta=meta,
mediainfo=mediainfo,
keyword_meta=share_query_meta,
@@ -701,12 +699,12 @@ class ChainBase(metaclass=ABCMeta):
share_query_meta, tmdbid, doubanid, bangumiid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = await share_helper.async_query(
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
meta=meta,
mtype=mtype,
keyword_meta=share_query_meta,
)
shared_params = share_helper.to_recognize_params(shared_item)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
async with async_fresh(not cache):
mediainfo = await self.async_run_module(

View File

@@ -34,7 +34,7 @@ from app.db.models.subscribe import Subscribe
from app.db.site_oper import SiteOper
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
@@ -709,7 +709,7 @@ class SubscribeChain(ChainBase):
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
SubscribeHelper().sub_reg_async({
MoviePilotServerHelper.sub_reg_async({
"name": title,
"year": year,
"type": metainfo.type.value,
@@ -890,7 +890,7 @@ class SubscribeChain(ChainBase):
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
await SubscribeHelper().async_sub_reg({
await MoviePilotServerHelper.async_sub_reg({
"name": title,
"year": year,
"type": metainfo.type.value,
@@ -1752,7 +1752,7 @@ class SubscribeChain(ChainBase):
logger.info(f'开始刷新follow用户分享订阅 ...')
success_count = 0
subscribeoper = SubscribeOper()
for share_sub in SubscribeHelper().get_shares():
for share_sub in MoviePilotServerHelper.get_subscribe_shares():
if global_vars.is_system_stopped:
break
uid = share_sub.get("share_uid")
@@ -2024,7 +2024,7 @@ class SubscribeChain(ChainBase):
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
SubscribeHelper().sub_done_async({
MoviePilotServerHelper.sub_done_async({
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id
})
@@ -2667,7 +2667,6 @@ class SubscribeChain(ChainBase):
return False, "请输入至少一个有效的订阅 ID"
subscribeoper = SubscribeOper()
subscribehelper = SubscribeHelper()
deleted = []
missing = []
for subscribe_id in subscribe_ids:
@@ -2677,7 +2676,7 @@ class SubscribeChain(ChainBase):
continue
deleted.append(subscribe.name)
subscribeoper.delete(subscribe_id)
subscribehelper.sub_done_async(
MoviePilotServerHelper.sub_done_async(
{
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
@@ -2706,7 +2705,6 @@ class SubscribeChain(ChainBase):
return
arg_strs = str(arg_str).split()
subscribeoper = SubscribeOper()
subscribehelper = SubscribeHelper()
for arg_str in arg_strs:
arg_str = arg_str.strip()
if not arg_str.isdigit():
@@ -2720,7 +2718,7 @@ class SubscribeChain(ChainBase):
# 删除订阅
subscribeoper.delete(subscribe_id)
# 统计订阅
subscribehelper.sub_done_async({
MoviePilotServerHelper.sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})

View File

@@ -25,6 +25,7 @@ from app.core.config import settings
from app.core.event import eventmanager
from app.db.plugindata_oper import PluginDataOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper
from app.helper.plugin import PluginHelper
from app.helper.sites import SitesHelper # noqa
from app.log import logger
@@ -591,6 +592,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
state, msg = PluginHelper().install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True)
elapsed_time = time.time() - start_time
if state:
MoviePilotServerHelper.install_plugin_reg(plugin_id=plugin.id, repo_url=plugin.repo_url)
logger.info(
f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},耗时:{elapsed_time:.2f}")
sync_plugins.append(plugin.id)

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, ""

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
app/helper/server.py Normal file

File diff suppressed because it is too large Load Diff

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

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 {}

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 ""

View File

@@ -36,7 +36,7 @@ from app.db.systemconfig_oper import SystemConfigOper
from app.helper.image import WallpaperHelper
from app.helper.message import MessageHelper
from app.helper.sites import SitesHelper # noqa
from app.helper.usage import UsageHelper
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas import Notification, NotificationType, Workflow
from app.schemas.types import EventType, SystemConfigKey
@@ -405,7 +405,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
},
"usage_report": {
"name": "安装版本统计上报",
"func": UsageHelper().report,
"func": MoviePilotServerHelper.report_usage,
"running": False,
},
}

View File

@@ -5,8 +5,8 @@ from fastapi import FastAPI
from app.chain.system import SystemChain
from app.core.config import global_vars
from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper
from app.helper.usage import UsageHelper
from app.startup.command_initializer import init_command, stop_command, restart_command
from app.startup.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor
@@ -31,7 +31,7 @@ async def init_extra():
# 重启完成
SystemChain().restart_finish()
# 上报当前安装版本
await UsageHelper().async_report()
await MoviePilotServerHelper.async_report_usage()
@asynccontextmanager

View File

@@ -21,7 +21,7 @@ from app.helper.display import DisplayHelper
from app.helper.doh import DohHelper
from app.helper.resource import ResourceHelper
from app.helper.message import MessageHelper, stop_message
from app.helper.subscribe import SubscribeHelper
from app.helper.server import MoviePilotServerHelper
from app.db import close_database
from app.db.systemconfig_oper import SystemConfigOper
from app.command import CommandChain
@@ -152,8 +152,11 @@ def init_modules():
ModuleManager()
# 启动事件消费
EventManager().start()
# 初始化订阅分享
SubscribeHelper()
# 初始化共享服务端状态
MoviePilotServerHelper.init_plugin_report()
MoviePilotServerHelper.init_subscribe_report()
MoviePilotServerHelper.get_user_uuid()
MoviePilotServerHelper.get_github_user()
# 初始化AI智能体
init_agent()
# 启动前端服务