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

289 lines
12 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 threading
import time
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()
self._lifecycle_lock = threading.RLock()
self._closed = False
@property
def stop_event(self) -> threading.Event:
"""返回供 watchfiles 监听的停止事件。"""
return self._stop_event
def reload(self, enabled: bool) -> None:
"""按当前配置停止旧线程,并在启用时创建新线程。"""
stopped = self.stop()
if enabled and stopped:
self.start()
def start(self) -> None:
"""启动唯一的守护监控线程。"""
with self._lifecycle_lock:
if self._closed:
self._logger.info("插件文件修改监测已进入停机封口,跳过启动")
return
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 reopen(self) -> bool:
"""为新的应用生命周期解除封口,仍有旧线程时拒绝重开。"""
with self._lifecycle_lock:
if self._thread and self._thread.is_alive():
self._logger.warning("旧插件文件监测线程仍在运行,无法开启新生命周期")
return False
self._thread = None
self._closed = False
return True
def stop(self, timeout: float = 5.0) -> bool:
"""临时停止监控线程,并返回其是否在预算内真正退出。"""
return self._stop_with_budget(timeout=timeout, close=False)
def close(self, timeout: float = 5.0) -> bool:
"""永久封口当前生命周期,并返回监控线程是否真正退出。"""
return self._stop_with_budget(timeout=timeout, close=True)
def _stop_with_budget(self, *, timeout: float, close: bool) -> bool:
"""在同一预算内取得生命周期锁、设置封口并等待线程退出。"""
timeout = max(0.0, timeout)
deadline = time.monotonic() + timeout
self._stop_event.set()
if not self._lifecycle_lock.acquire(timeout=timeout):
self._logger.warning(
f"插件文件修改监测线程在{timeout:g}秒内未能取得停机所有权。"
)
return False
try:
if close:
self._closed = True
thread = self._thread
self._stop_event.set()
if not thread or not thread.is_alive():
self._thread = None
self._logger.info("未启用插件文件修改监测,无需停止")
return True
self._logger.info("正在停止插件文件修改监测...")
thread.join(timeout=max(0.0, deadline - time.monotonic()))
if thread.is_alive():
self._logger.warning(
f"插件文件修改监测线程在{timeout:g}秒内未能正常停止。"
)
return False
self._thread = None
self._logger.info("插件文件修改监测停止完成")
return True
finally:
self._lifecycle_lock.release()
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')} 依赖文件变化,"
"请重新安装本地插件以安装依赖"
)