mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-08 17:20:22 +08:00
feat(plugin): support local plugin sources
This commit is contained in:
@@ -6,6 +6,7 @@ import importlib.util
|
||||
import inspect
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -51,6 +52,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
# 监控停止事件
|
||||
self._stop_monitor_event = threading.Event()
|
||||
# 本地插件同步写入运行目录后的短时忽略窗口
|
||||
self._recent_local_sync: Dict[str, float] = {}
|
||||
# 开发者模式监测插件修改
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
self.__start_monitor()
|
||||
@@ -308,11 +311,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
运行 watchfiles 监视器的主循环。
|
||||
"""
|
||||
# 监视插件目录
|
||||
plugins_path = str(settings.ROOT_PATH / "app" / "plugins")
|
||||
plugin_paths = [str(settings.ROOT_PATH / "app" / "plugins")]
|
||||
for local_path in PluginHelper.get_local_source_paths():
|
||||
if local_path.exists() and local_path.is_dir():
|
||||
plugin_paths.append(str(local_path))
|
||||
logger.info(">>> 监控线程已启动,准备进入watch循环...")
|
||||
# 使用 watchfiles 监视目录变化,并响应变化事件
|
||||
# Todo: yield_on_timeout = True 时,每秒检查停止事件,会返回空集合;后续可以考虑用来做心跳之类的功能?
|
||||
for changes in watch(plugins_path, stop_event=self._stop_monitor_event, rust_timeout=1000,
|
||||
for changes in watch(*plugin_paths, stop_event=self._stop_monitor_event, rust_timeout=1000,
|
||||
yield_on_timeout=True):
|
||||
# 如果收到停止事件,退出循环
|
||||
if not changes:
|
||||
@@ -320,18 +326,56 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
# 处理变化事件
|
||||
plugins_to_reload = set()
|
||||
local_plugins_to_sync = {}
|
||||
for _change_type, path_str in changes:
|
||||
event_path = Path(path_str)
|
||||
|
||||
# 跳过非 .py 文件以及 pycache 目录中的文件
|
||||
if not event_path.name.endswith(".py") or "__pycache__" in event_path.parts:
|
||||
# 跳过 pycache 目录中的文件
|
||||
if "__pycache__" in event_path.parts:
|
||||
continue
|
||||
|
||||
if event_path.name == "requirements.txt":
|
||||
candidate = self._get_local_plugin_candidate_from_path(event_path)
|
||||
if candidate:
|
||||
if candidate.get("compatible") is False:
|
||||
logger.info(
|
||||
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
|
||||
f"但跳过处理:{candidate.get('skip_reason')}"
|
||||
)
|
||||
continue
|
||||
logger.warning(f"检测到本地插件 {candidate.get('id')} 依赖文件变化,请重新安装本地插件以安装依赖")
|
||||
continue
|
||||
|
||||
# 跳过非 .py 文件
|
||||
if not event_path.name.endswith(".py"):
|
||||
continue
|
||||
|
||||
# 解析插件ID
|
||||
pid = self._get_plugin_id_from_path(event_path)
|
||||
# 跳过无效插件文件
|
||||
if pid:
|
||||
# 收集需要重载的插件ID,自动去重,避免重复重载
|
||||
runtime_pid = self._get_plugin_id_from_path(event_path)
|
||||
local_candidate = self._get_local_plugin_candidate_from_path(event_path) if not runtime_pid else None
|
||||
if runtime_pid:
|
||||
last_sync_time = self._recent_local_sync.get(runtime_pid)
|
||||
if last_sync_time and time.time() - last_sync_time < 2:
|
||||
logger.debug(f"忽略本地插件同步产生的运行目录变化:{runtime_pid}")
|
||||
continue
|
||||
# 运行目录变化只重载,不能反向触发本地同步。
|
||||
plugins_to_reload.add(runtime_pid)
|
||||
elif local_candidate:
|
||||
if local_candidate.get("compatible") is False:
|
||||
package_version = local_candidate.get("package_version")
|
||||
source_root = f"plugins.{package_version}" if package_version else "plugins"
|
||||
logger.info(
|
||||
f"检测到本地插件 {local_candidate.get('id')} 文件变化,来源:{source_root},"
|
||||
f"文件:{event_path},但跳过同步:{local_candidate.get('skip_reason')}"
|
||||
)
|
||||
continue
|
||||
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path)
|
||||
|
||||
for pid, (candidate, event_path) in local_plugins_to_sync.items():
|
||||
package_version = candidate.get("package_version")
|
||||
source_root = f"plugins.{package_version}" if package_version else "plugins"
|
||||
logger.info(f"检测到本地插件 {pid} 文件变化,来源:{source_root},文件:{event_path}")
|
||||
if self._sync_local_plugin_if_installed(pid, candidate):
|
||||
plugins_to_reload.add(pid)
|
||||
|
||||
# 触发重载
|
||||
@@ -351,6 +395,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
:return: 插件ID字符串,如果不是有效插件文件则返回 None。
|
||||
"""
|
||||
try:
|
||||
event_path = event_path.resolve()
|
||||
plugins_root = settings.ROOT_PATH / "app" / "plugins"
|
||||
# 确保修改的文件在 plugins 目录下
|
||||
if not event_path.is_relative_to(plugins_root):
|
||||
@@ -389,6 +434,78 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
logger.error(f"从路径解析插件ID时出错: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_local_plugin_candidate_from_path(event_path: Path) -> Optional[dict]:
|
||||
"""
|
||||
根据本地插件来源路径解析具体插件候选,保留 plugins/plugins.v2 来源差异。
|
||||
"""
|
||||
try:
|
||||
event_path = event_path.resolve()
|
||||
for local_path in PluginHelper.get_local_source_paths():
|
||||
if not local_path.exists() or not local_path.is_dir():
|
||||
continue
|
||||
if not event_path.is_relative_to(local_path):
|
||||
continue
|
||||
try:
|
||||
relative_parts = event_path.relative_to(local_path).parts
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if len(relative_parts) < 2:
|
||||
continue
|
||||
if relative_parts[0] == "plugins":
|
||||
package_version = ""
|
||||
elif relative_parts[0].startswith("plugins."):
|
||||
package_version = relative_parts[0].split(".", 1)[1]
|
||||
else:
|
||||
continue
|
||||
plugin_dir_name = relative_parts[1]
|
||||
candidate = PluginHelper().get_local_plugin_candidate(
|
||||
pid=plugin_dir_name,
|
||||
package_version=package_version,
|
||||
source_path=local_path,
|
||||
strict_compat=False
|
||||
)
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"从本地来源路径解析插件候选时出错: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _sync_local_plugin_if_installed(pid: str, candidate: Optional[dict] = None) -> bool:
|
||||
"""
|
||||
已安装本地插件源码变化时,同步到运行目录。
|
||||
"""
|
||||
installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
if pid not in installed_plugins:
|
||||
logger.info(f"本地插件 {pid} 尚未安装,跳过自动同步和热重载")
|
||||
return False
|
||||
|
||||
candidate = candidate or PluginHelper().get_local_plugin_candidate(pid)
|
||||
if not candidate:
|
||||
return False
|
||||
|
||||
source_dir = Path(candidate.get("path"))
|
||||
dest_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
|
||||
try:
|
||||
if source_dir.resolve() == dest_dir.resolve():
|
||||
return True
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir, ignore_errors=True)
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
dest_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
|
||||
)
|
||||
PluginManager()._recent_local_sync[pid] = time.time()
|
||||
logger.info(f"已同步本地插件 {pid}:{source_dir} -> {dest_dir}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"同步本地插件 {pid} 失败:{e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __stop_plugin(plugin: Any):
|
||||
"""
|
||||
@@ -1116,6 +1233,31 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||
return plugins
|
||||
|
||||
def get_local_source_plugins(self) -> List[schemas.Plugin]:
|
||||
"""
|
||||
获取本地插件来源目录中的插件信息。
|
||||
"""
|
||||
plugins = []
|
||||
installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
local_candidates = PluginHelper().get_local_plugin_candidates()
|
||||
for pid, plugin_info in local_candidates.items():
|
||||
package_version = plugin_info.get("package_version")
|
||||
plugin = self._process_plugin_info(
|
||||
pid=pid,
|
||||
plugin_info=plugin_info,
|
||||
market=PluginHelper.make_local_repo_url(pid),
|
||||
installed_apps=installed_apps,
|
||||
add_time=0,
|
||||
package_version=package_version
|
||||
)
|
||||
if not plugin:
|
||||
continue
|
||||
plugin.is_local = True
|
||||
plugins.append(plugin)
|
||||
|
||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def is_plugin_exists(pid: str, version: str = None) -> bool:
|
||||
"""
|
||||
@@ -1194,18 +1336,41 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
# 将未出现在高版本插件列表中的 v1 插件加入 all_plugins
|
||||
higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins}
|
||||
all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids])
|
||||
# 去重
|
||||
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
|
||||
# 所有插件按 repo 在设置中的顺序排序
|
||||
all_plugins.sort(
|
||||
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
|
||||
)
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
max_versions[p.id] = p.plugin_version
|
||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||
markets = [item for item in settings.PLUGIN_MARKET.split(",") if item]
|
||||
|
||||
def repo_order(plugin: schemas.Plugin) -> int:
|
||||
if PluginHelper.is_local_repo_url(plugin.repo_url):
|
||||
return len(markets) + 1
|
||||
if plugin.repo_url in markets:
|
||||
return markets.index(plugin.repo_url)
|
||||
return len(markets)
|
||||
|
||||
# 去重:同 ID + 版本优先保留市场来源,其次按来源顺序稳定保留。
|
||||
dedup_plugins = {}
|
||||
for plugin in sorted(all_plugins, key=repo_order):
|
||||
key = f"{plugin.id}{plugin.plugin_version}"
|
||||
exists = dedup_plugins.get(key)
|
||||
if not exists:
|
||||
dedup_plugins[key] = plugin
|
||||
continue
|
||||
if PluginHelper.is_local_repo_url(exists.repo_url) and not PluginHelper.is_local_repo_url(plugin.repo_url):
|
||||
dedup_plugins[key] = plugin
|
||||
|
||||
# 相同 ID 的插件保留版本号最大的版本;同版本市场来源优先。
|
||||
result_by_id = {}
|
||||
for plugin in sorted(dedup_plugins.values(), key=repo_order):
|
||||
exists = result_by_id.get(plugin.id)
|
||||
if not exists:
|
||||
result_by_id[plugin.id] = plugin
|
||||
continue
|
||||
if StringUtils.compare_version(plugin.plugin_version, ">", exists.plugin_version):
|
||||
result_by_id[plugin.id] = plugin
|
||||
elif plugin.plugin_version == exists.plugin_version \
|
||||
and PluginHelper.is_local_repo_url(exists.repo_url) \
|
||||
and not PluginHelper.is_local_repo_url(plugin.repo_url):
|
||||
result_by_id[plugin.id] = plugin
|
||||
|
||||
result = list(result_by_id.values())
|
||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user