mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
246 lines
9.6 KiB
Python
246 lines
9.6 KiB
Python
"""插件运行目录与本地仓库的文件变化监控。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
import threading
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]]
|
||
RuntimePluginResolver = Callable[[Path], Optional[str]]
|
||
MonitorSuppression = Callable[[str], bool]
|
||
LocalCandidateResolver = Callable[[Path], Optional[dict]]
|
||
LocalPluginSync = Callable[[str, Optional[dict]], bool]
|
||
PluginReloader = Callable[[str], Any]
|
||
DependencyManifestStatus = Callable[[Path], Optional[bool]]
|
||
WatchFunction = Callable[..., Any]
|
||
|
||
|
||
class PluginMonitorController:
|
||
"""独立管理插件文件监控线程的启动、停止和重建。"""
|
||
|
||
def __init__(self, *, runner: Callable[[], None], log: Any) -> None:
|
||
"""保存监控循环入口和日志端口,线程状态仅由本组件持有。"""
|
||
self._runner = runner
|
||
self._logger = log
|
||
self._thread: Optional[threading.Thread] = None
|
||
self._stop_event = threading.Event()
|
||
|
||
@property
|
||
def stop_event(self) -> threading.Event:
|
||
"""返回供 watchfiles 监听的停止事件。"""
|
||
return self._stop_event
|
||
|
||
def reload(self, enabled: bool) -> None:
|
||
"""按当前配置停止旧线程,并在启用时创建新线程。"""
|
||
self.stop()
|
||
if enabled:
|
||
self.start()
|
||
|
||
def start(self) -> None:
|
||
"""启动唯一的守护监控线程。"""
|
||
if self._thread and self._thread.is_alive():
|
||
self._logger.info("插件文件修改监测已经在运行中...")
|
||
return
|
||
self._logger.info("开始监测插件文件修改...")
|
||
self._stop_event.clear()
|
||
self._thread = threading.Thread(target=self._runner, daemon=True)
|
||
self._thread.start()
|
||
|
||
def stop(self) -> None:
|
||
"""请求监控线程退出,并在限定时间内等待其清理。"""
|
||
if not self._thread or not self._thread.is_alive():
|
||
self._logger.info("未启用插件文件修改监测,无需停止")
|
||
return
|
||
self._logger.info("正在停止插件文件修改监测...")
|
||
self._stop_event.set()
|
||
self._thread.join(timeout=5)
|
||
if self._thread.is_alive():
|
||
self._logger.warning("插件文件修改监测线程在5秒内未能正常停止。")
|
||
self._thread = None
|
||
self._logger.info("插件文件修改监测停止完成")
|
||
|
||
|
||
class PluginChangeMonitor:
|
||
"""把文件变化归并为本地同步和运行态重载动作。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
runtime_root: Path,
|
||
local_roots: Callable[[], list[Path]],
|
||
stop_event: Any,
|
||
recent_sync: dict[str, float],
|
||
federated_change: FederatedChangeResolver,
|
||
runtime_plugin: RuntimePluginResolver,
|
||
local_candidate: LocalCandidateResolver,
|
||
sync_local: LocalPluginSync,
|
||
reload_plugin: PluginReloader,
|
||
dependency_manifest_status: DependencyManifestStatus,
|
||
watch: WatchFunction,
|
||
log: Any,
|
||
monitor_suppressed: Optional[MonitorSuppression] = None,
|
||
) -> None:
|
||
"""保存监控路径、变化解析器和副作用回调。"""
|
||
self._runtime_root = runtime_root
|
||
self._local_roots = local_roots
|
||
self._stop_event = stop_event
|
||
self._recent_sync = recent_sync
|
||
self._federated_change = federated_change
|
||
self._runtime_plugin = runtime_plugin
|
||
self._monitor_suppressed = monitor_suppressed or (lambda _plugin_id: False)
|
||
self._local_candidate = local_candidate
|
||
self._sync_local = sync_local
|
||
self._reload_plugin = reload_plugin
|
||
self._dependency_manifest_status = dependency_manifest_status
|
||
self._watch = watch
|
||
self._logger = log
|
||
|
||
def run(self) -> None:
|
||
"""运行 watchfiles 主循环并按批次同步、重载插件。"""
|
||
plugin_paths = [str(self._runtime_root)]
|
||
plugin_paths.extend(
|
||
str(path)
|
||
for path in self._local_roots()
|
||
if path.exists() and path.is_dir()
|
||
)
|
||
self._logger.info(">>> 监控线程已启动,准备进入watch循环...")
|
||
for changes in self._watch(
|
||
*plugin_paths,
|
||
stop_event=self._stop_event,
|
||
rust_timeout=1000,
|
||
yield_on_timeout=True,
|
||
):
|
||
if not changes:
|
||
continue
|
||
self._process_changes(changes)
|
||
|
||
def _process_changes(self, changes: Any) -> None:
|
||
"""把一批文件事件归并为最多一次同步和一次重载。"""
|
||
plugins_to_reload = set()
|
||
local_plugins_to_sync = {}
|
||
for _change_type, path_str in changes:
|
||
event_path = Path(path_str)
|
||
if "__pycache__" in event_path.parts:
|
||
continue
|
||
manifest_status = self._dependency_manifest_status(event_path)
|
||
if manifest_status is not None:
|
||
self._handle_dependency_manifest_change(
|
||
event_path,
|
||
active=manifest_status,
|
||
)
|
||
continue
|
||
|
||
federated_change = self._federated_change(event_path)
|
||
if federated_change:
|
||
plugin_id, candidate, remote_entry_ready = federated_change
|
||
if candidate and remote_entry_ready:
|
||
if candidate.get("compatible") is False:
|
||
self._logger.info(
|
||
f"检测到本地插件 {plugin_id} 联邦构建产物变化,"
|
||
f"但跳过同步:{candidate.get('skip_reason')}"
|
||
)
|
||
elif plugin_id not in local_plugins_to_sync:
|
||
local_plugins_to_sync[plugin_id] = (
|
||
candidate,
|
||
event_path,
|
||
False,
|
||
)
|
||
continue
|
||
|
||
if event_path.suffix != ".py":
|
||
continue
|
||
runtime_plugin_id = self._runtime_plugin(event_path)
|
||
if runtime_plugin_id and self._monitor_suppressed(runtime_plugin_id):
|
||
self._logger.debug(
|
||
f"插件 {runtime_plugin_id} 正在写入,跳过本批文件监控重载"
|
||
)
|
||
continue
|
||
candidate = (
|
||
self._local_candidate(event_path)
|
||
if not runtime_plugin_id
|
||
else None
|
||
)
|
||
if runtime_plugin_id:
|
||
last_sync_time = self._recent_sync.get(runtime_plugin_id)
|
||
if last_sync_time and time.time() - last_sync_time < 2:
|
||
continue
|
||
plugins_to_reload.add(runtime_plugin_id)
|
||
elif candidate:
|
||
if candidate.get("compatible") is False:
|
||
package_version = candidate.get("package_version")
|
||
source_root = (
|
||
f"plugins.{package_version}"
|
||
if package_version
|
||
else "plugins"
|
||
)
|
||
self._logger.info(
|
||
f"检测到本地插件 {candidate.get('id')} 文件变化,"
|
||
f"来源:{source_root},文件:{event_path},"
|
||
f"但跳过同步:{candidate.get('skip_reason')}"
|
||
)
|
||
continue
|
||
local_plugins_to_sync[candidate.get("id")] = (
|
||
candidate,
|
||
event_path,
|
||
True,
|
||
)
|
||
|
||
for plugin_id, (candidate, event_path, should_reload) in (
|
||
local_plugins_to_sync.items()
|
||
):
|
||
package_version = candidate.get("package_version")
|
||
source_root = (
|
||
f"plugins.{package_version}" if package_version else "plugins"
|
||
)
|
||
change_name = "Python 文件" if should_reload else "联邦构建产物"
|
||
self._logger.info(
|
||
f"检测到本地插件 {plugin_id} {change_name}变化,"
|
||
f"来源:{source_root},文件:{event_path}"
|
||
)
|
||
if self._sync_local(plugin_id, candidate) and should_reload:
|
||
plugins_to_reload.add(plugin_id)
|
||
|
||
if not plugins_to_reload:
|
||
return
|
||
self._logger.info(
|
||
f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}"
|
||
)
|
||
for plugin_id in plugins_to_reload:
|
||
try:
|
||
self._reload_plugin(plugin_id)
|
||
except Exception as err:
|
||
self._logger.error(
|
||
f"插件 {plugin_id} 热重载失败: {err}",
|
||
exc_info=True,
|
||
)
|
||
|
||
def _handle_dependency_manifest_change(
|
||
self,
|
||
event_path: Path,
|
||
*,
|
||
active: bool,
|
||
) -> None:
|
||
"""记录依赖文件变化,但不在监控线程中隐式安装依赖。"""
|
||
candidate = self._local_candidate(event_path)
|
||
if not candidate:
|
||
return
|
||
if candidate.get("compatible") is False:
|
||
self._logger.info(
|
||
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
|
||
f"但跳过处理:{candidate.get('skip_reason')}"
|
||
)
|
||
return
|
||
if not active:
|
||
self._logger.debug(
|
||
f"检测到本地插件 {candidate.get('id')} 非生效依赖文件变化:"
|
||
f"{event_path.name}"
|
||
)
|
||
return
|
||
self._logger.warning(
|
||
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
|
||
"请重新安装本地插件以安装依赖"
|
||
)
|