Files
MoviePilot/app/runtime/extensions/plugin/monitor.py
T

221 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件运行目录与本地仓库的文件变化监控。"""
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]]
LocalCandidateResolver = Callable[[Path], Optional[dict]]
LocalPluginSync = Callable[[str, Optional[dict]], bool]
PluginReloader = Callable[[str], Any]
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,
watch: WatchFunction,
log: Any,
) -> 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._local_candidate = local_candidate
self._sync_local = sync_local
self._reload_plugin = reload_plugin
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
if event_path.name == "requirements.txt":
self._handle_requirements_change(event_path)
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)
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_requirements_change(self, event_path: Path) -> 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
self._logger.warning(
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
"请重新安装本地插件以安装依赖"
)