432 lines
19 KiB
Python
Executable File
432 lines
19 KiB
Python
Executable File
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from config import config_manager
|
|
|
|
|
|
class PluginBase:
|
|
def __init__(self, manifest: Dict[str, Any], plugin_dir: Path, config: Optional[Dict[str, Any]] = None):
|
|
self.manifest = manifest
|
|
self.plugin_dir = plugin_dir
|
|
self.id = manifest["id"]
|
|
self.name = manifest.get("name", self.id)
|
|
self.version = manifest.get("version", "0.0.1")
|
|
self.description = manifest.get("description", "")
|
|
self.plugin_type = manifest.get("type", "tool")
|
|
self.config_schema = manifest.get("config_schema", [])
|
|
self.config = config or {}
|
|
self.enabled = False
|
|
|
|
async def startup(self):
|
|
return None
|
|
|
|
async def shutdown(self):
|
|
return None
|
|
|
|
async def on_config_updated(self, config: Dict[str, Any]):
|
|
self.config = config
|
|
|
|
async def search(self, keyword: str, page: int = 1) -> Dict[str, Any]:
|
|
raise NotImplementedError("当前插件未实现搜索能力")
|
|
|
|
async def test_connection(self) -> Dict[str, Any]:
|
|
raise NotImplementedError("当前插件未实现连通性测试")
|
|
|
|
async def sync_index(self, force: bool = False) -> Dict[str, Any]:
|
|
raise NotImplementedError("当前插件未实现同步能力")
|
|
|
|
async def stream_search(self, keyword: str, page: int = 1):
|
|
yield await self.search(keyword, page)
|
|
|
|
async def execute_action(self, action: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
raise NotImplementedError("当前插件未实现动作能力")
|
|
|
|
|
|
class PluginManager:
|
|
def __init__(self, plugins_dir: str = "plugins"):
|
|
self.plugins_dir = Path(plugins_dir)
|
|
self.plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
self._plugins: Dict[str, PluginBase] = {}
|
|
self._manifests: Dict[str, Dict[str, Any]] = {}
|
|
self._search_jobs: Dict[str, Dict[str, Any]] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def initialize(self):
|
|
await self.scan_plugins()
|
|
|
|
async def shutdown(self):
|
|
for plugin in list(self._plugins.values()):
|
|
try:
|
|
await plugin.shutdown()
|
|
except Exception:
|
|
continue
|
|
for job in self._search_jobs.values():
|
|
task = job.get("task")
|
|
if task and not task.done():
|
|
task.cancel()
|
|
self._plugins.clear()
|
|
self._manifests.clear()
|
|
self._search_jobs.clear()
|
|
|
|
async def scan_plugins(self) -> List[Dict[str, Any]]:
|
|
async with self._lock:
|
|
discovered_ids: List[str] = []
|
|
for plugin_dir in sorted(self.plugins_dir.iterdir()):
|
|
if not plugin_dir.is_dir():
|
|
continue
|
|
manifest_path = plugin_dir / "manifest.json"
|
|
if not manifest_path.exists():
|
|
continue
|
|
|
|
manifest = self._load_manifest(manifest_path, plugin_dir)
|
|
plugin = await self._load_plugin_instance(manifest, plugin_dir)
|
|
self._manifests[manifest["id"]] = manifest
|
|
self._plugins[manifest["id"]] = plugin
|
|
discovered_ids.append(manifest["id"])
|
|
|
|
removed_ids = [plugin_id for plugin_id in self._plugins.keys() if plugin_id not in discovered_ids]
|
|
for plugin_id in removed_ids:
|
|
plugin = self._plugins.pop(plugin_id, None)
|
|
self._manifests.pop(plugin_id, None)
|
|
if plugin:
|
|
try:
|
|
await plugin.shutdown()
|
|
except Exception:
|
|
pass
|
|
|
|
return self.list_plugins()
|
|
|
|
def _load_manifest(self, manifest_path: Path, plugin_dir: Path) -> Dict[str, Any]:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["plugin_dir"] = str(plugin_dir)
|
|
manifest["config_file"] = str((plugin_dir / "config.json").resolve())
|
|
frontend = manifest.get("frontend") or {}
|
|
frontend_entry = frontend.get("entry") or manifest.get("frontend_entry")
|
|
frontend_kind = frontend.get("kind") or manifest.get("frontend_kind")
|
|
config_entry = frontend.get("config_entry") or manifest.get("config_frontend_entry")
|
|
config_kind = frontend.get("config_kind") or manifest.get("config_frontend_kind")
|
|
if frontend_entry:
|
|
manifest["frontend_entry"] = str(frontend_entry).replace("\\", "/")
|
|
else:
|
|
manifest["frontend_entry"] = None
|
|
if not frontend_kind and manifest["frontend_entry"]:
|
|
frontend_kind = "module" if Path(manifest["frontend_entry"]).suffix.lower() in {".js", ".mjs"} else "iframe"
|
|
manifest["frontend_kind"] = frontend_kind or None
|
|
if config_entry:
|
|
manifest["config_frontend_entry"] = str(config_entry).replace("\\", "/")
|
|
else:
|
|
manifest["config_frontend_entry"] = None
|
|
if not config_kind and manifest["config_frontend_entry"]:
|
|
config_kind = "module" if Path(manifest["config_frontend_entry"]).suffix.lower() in {".js", ".mjs"} else "iframe"
|
|
manifest["config_frontend_kind"] = config_kind or None
|
|
|
|
default_config_path = plugin_dir / "config.default.json"
|
|
default_config = {}
|
|
if default_config_path.exists():
|
|
default_config = json.loads(default_config_path.read_text(encoding="utf-8"))
|
|
manifest["default_config"] = default_config
|
|
return manifest
|
|
|
|
def _get_plugin_config_path(self, manifest: Dict[str, Any]) -> Path:
|
|
return Path(manifest.get("config_file") or (Path(manifest["plugin_dir"]) / "config.json")).resolve()
|
|
|
|
def _read_plugin_config_file(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
|
|
config_path = self._get_plugin_config_path(manifest)
|
|
if not config_path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
def _write_plugin_config_file(self, manifest: Dict[str, Any], config: Dict[str, Any]):
|
|
config_path = self._get_plugin_config_path(manifest)
|
|
config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
def _get_plugin_asset_version(self, manifest: Dict[str, Any], asset_path: Optional[str]) -> str:
|
|
if not asset_path:
|
|
return ""
|
|
try:
|
|
plugin_dir = Path(manifest["plugin_dir"]).resolve()
|
|
target_path = (plugin_dir / asset_path).resolve()
|
|
if not target_path.exists() or not target_path.is_file():
|
|
return ""
|
|
return str(target_path.stat().st_mtime_ns)
|
|
except Exception:
|
|
return ""
|
|
|
|
async def _load_plugin_instance(self, manifest: Dict[str, Any], plugin_dir: Path) -> PluginBase:
|
|
plugin_id = manifest["id"]
|
|
enabled = await self.is_enabled(plugin_id, manifest)
|
|
config = await self.get_plugin_config(plugin_id, manifest)
|
|
entry_path = plugin_dir / manifest.get("entry", "plugin.py")
|
|
module_name = f"litepan_plugin_{plugin_id}_{int(time.time() * 1000)}"
|
|
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
|
if not spec or not spec.loader:
|
|
raise RuntimeError(f"插件 {plugin_id} 加载失败")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
plugin_factory = getattr(module, "create_plugin", None)
|
|
if callable(plugin_factory):
|
|
plugin = plugin_factory(manifest, plugin_dir, config)
|
|
else:
|
|
plugin_cls = getattr(module, "Plugin", None)
|
|
if plugin_cls is None:
|
|
raise RuntimeError(f"插件 {plugin_id} 未提供 create_plugin 或 Plugin")
|
|
plugin = plugin_cls(manifest, plugin_dir, config)
|
|
|
|
old_plugin = self._plugins.get(plugin_id)
|
|
if old_plugin:
|
|
try:
|
|
await old_plugin.shutdown()
|
|
except Exception:
|
|
pass
|
|
|
|
if enabled:
|
|
await plugin.startup()
|
|
plugin.enabled = enabled
|
|
return plugin
|
|
|
|
async def is_enabled(self, plugin_id: str, manifest: Optional[Dict[str, Any]] = None) -> bool:
|
|
stored = await config_manager.get_async(f"plugin_enabled:{plugin_id}")
|
|
if stored is None:
|
|
return bool((manifest or self._manifests.get(plugin_id) or {}).get("enabled_by_default", True))
|
|
return bool(stored)
|
|
|
|
async def get_plugin_config(self, plugin_id: str, manifest: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
source_manifest = manifest or self._manifests.get(plugin_id) or {}
|
|
defaults = dict(source_manifest.get("default_config") or {})
|
|
file_config = self._read_plugin_config_file(source_manifest)
|
|
if file_config:
|
|
defaults.update(file_config)
|
|
return defaults
|
|
|
|
stored = await config_manager.get_async(f"plugin_config:{plugin_id}") or {}
|
|
defaults.update(stored)
|
|
if stored and source_manifest.get("plugin_dir"):
|
|
self._write_plugin_config_file(source_manifest, defaults)
|
|
return defaults
|
|
|
|
def get_plugin(self, plugin_id: str) -> Optional[PluginBase]:
|
|
return self._plugins.get(plugin_id)
|
|
|
|
def list_plugins(self) -> List[Dict[str, Any]]:
|
|
plugin_items = []
|
|
for plugin_id, manifest in sorted(self._manifests.items(), key=lambda item: item[1].get("name", item[0])):
|
|
plugin = self._plugins.get(plugin_id)
|
|
frontend_entry = manifest.get("frontend_entry")
|
|
frontend_kind = manifest.get("frontend_kind")
|
|
config_frontend_entry = manifest.get("config_frontend_entry")
|
|
config_frontend_kind = manifest.get("config_frontend_kind")
|
|
frontend_asset_version = self._get_plugin_asset_version(manifest, frontend_entry)
|
|
config_frontend_asset_version = self._get_plugin_asset_version(manifest, config_frontend_entry)
|
|
plugin_items.append({
|
|
"id": plugin_id,
|
|
"name": manifest.get("name", plugin_id),
|
|
"author": manifest.get("author", ""),
|
|
"version": manifest.get("version", "0.0.1"),
|
|
"description": manifest.get("description", ""),
|
|
"type": manifest.get("type", "tool"),
|
|
"enabled": bool(plugin and plugin.enabled),
|
|
"has_frontend": bool(frontend_entry),
|
|
"frontend_entry": frontend_entry,
|
|
"frontend_kind": frontend_kind,
|
|
"frontend_entry_url": f"/api/plugins/{plugin_id}/ui/{frontend_entry}" if frontend_entry else "",
|
|
"frontend_asset_version": frontend_asset_version,
|
|
"has_config_frontend": bool(config_frontend_entry),
|
|
"config_frontend_entry": config_frontend_entry,
|
|
"config_frontend_kind": config_frontend_kind,
|
|
"config_frontend_entry_url": f"/api/plugins/{plugin_id}/ui/{config_frontend_entry}" if config_frontend_entry else "",
|
|
"config_frontend_asset_version": config_frontend_asset_version,
|
|
"config_schema": manifest.get("config_schema", []),
|
|
"config": plugin.config if plugin else manifest.get("default_config", {}),
|
|
})
|
|
return plugin_items
|
|
|
|
def get_plugin_ui_file(self, plugin_id: str, asset_path: str) -> Path:
|
|
manifest = self._manifests.get(plugin_id)
|
|
if not manifest:
|
|
raise KeyError(f"插件不存在: {plugin_id}")
|
|
|
|
plugin_dir = Path(manifest["plugin_dir"]).resolve()
|
|
target_path = (plugin_dir / asset_path).resolve()
|
|
if plugin_dir not in target_path.parents and target_path != plugin_dir:
|
|
raise RuntimeError("插件资源路径非法")
|
|
if not target_path.exists() or not target_path.is_file():
|
|
raise FileNotFoundError(f"插件资源不存在: {asset_path}")
|
|
return target_path
|
|
|
|
async def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
|
|
manifest = self._manifests.get(plugin_id)
|
|
if not manifest:
|
|
raise KeyError(f"插件不存在: {plugin_id}")
|
|
|
|
await config_manager.set_async(f"plugin_enabled:{plugin_id}", enabled)
|
|
plugin = self._plugins.get(plugin_id)
|
|
|
|
if enabled and plugin is None:
|
|
plugin = await self._load_plugin_instance(manifest, Path(manifest["plugin_dir"]))
|
|
self._plugins[plugin_id] = plugin
|
|
elif enabled and plugin is not None and not plugin.enabled:
|
|
await plugin.startup()
|
|
plugin.enabled = True
|
|
elif not enabled and plugin is not None and plugin.enabled:
|
|
await plugin.shutdown()
|
|
plugin.enabled = False
|
|
|
|
return next(item for item in self.list_plugins() if item["id"] == plugin_id)
|
|
|
|
async def update_plugin_config(self, plugin_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
manifest = self._manifests.get(plugin_id)
|
|
if not manifest:
|
|
raise KeyError(f"插件不存在: {plugin_id}")
|
|
|
|
merged_config = dict(manifest.get("default_config") or {})
|
|
merged_config.update(config or {})
|
|
self._write_plugin_config_file(manifest, merged_config)
|
|
|
|
plugin = self._plugins.get(plugin_id)
|
|
if plugin:
|
|
await plugin.on_config_updated(merged_config)
|
|
|
|
return next(item for item in self.list_plugins() if item["id"] == plugin_id)
|
|
|
|
async def execute_search(self, plugin_id: str, keyword: str, page: int = 1) -> Dict[str, Any]:
|
|
plugin = self._plugins.get(plugin_id)
|
|
if not plugin or not plugin.enabled:
|
|
raise KeyError(f"插件未启用或不存在: {plugin_id}")
|
|
if not hasattr(plugin, "search"):
|
|
raise RuntimeError("插件不支持搜索能力")
|
|
return await plugin.search(keyword=keyword, page=page)
|
|
|
|
async def test_plugin_connection(self, plugin_id: str) -> Dict[str, Any]:
|
|
plugin = self._plugins.get(plugin_id)
|
|
if not plugin or not plugin.enabled:
|
|
raise KeyError(f"插件未启用或不存在: {plugin_id}")
|
|
if not hasattr(plugin, "test_connection"):
|
|
raise RuntimeError("插件不支持连通性测试")
|
|
return await plugin.test_connection()
|
|
|
|
async def sync_plugin_index(self, plugin_id: str, force: bool = False) -> Dict[str, Any]:
|
|
plugin = self._plugins.get(plugin_id)
|
|
if not plugin or not plugin.enabled:
|
|
raise KeyError(f"插件未启用或不存在: {plugin_id}")
|
|
if not hasattr(plugin, "sync_index"):
|
|
raise RuntimeError("插件不支持同步能力")
|
|
return await plugin.sync_index(force=force)
|
|
|
|
async def execute_plugin_action(self, plugin_id: str, action: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
plugin = self._plugins.get(plugin_id)
|
|
if not plugin or not plugin.enabled:
|
|
raise KeyError(f"插件未启用或不存在: {plugin_id}")
|
|
return await plugin.execute_action(action, payload or {})
|
|
|
|
async def start_search_job(self, plugin_id: str, keyword: str, page: int = 1) -> Dict[str, Any]:
|
|
plugin = self._plugins.get(plugin_id)
|
|
if not plugin or not plugin.enabled:
|
|
raise KeyError(f"插件未启用或不存在: {plugin_id}")
|
|
|
|
job_id = uuid.uuid4().hex
|
|
job = {
|
|
"job_id": job_id,
|
|
"plugin_id": plugin_id,
|
|
"keyword": keyword,
|
|
"page": page,
|
|
"status": "running",
|
|
"items": [],
|
|
"message": "搜索已启动",
|
|
"error": "",
|
|
"updated_at": time.time(),
|
|
"progress": {
|
|
"processed_channels": 0,
|
|
"total_channels": 0,
|
|
"result_count": 0,
|
|
},
|
|
}
|
|
task = asyncio.create_task(self._run_search_job(job_id, plugin, keyword, page))
|
|
job["task"] = task
|
|
self._search_jobs[job_id] = job
|
|
return self._serialize_search_job(job)
|
|
|
|
async def _run_search_job(self, job_id: str, plugin: PluginBase, keyword: str, page: int):
|
|
job = self._search_jobs[job_id]
|
|
try:
|
|
async for payload in plugin.stream_search(keyword, page):
|
|
if not payload:
|
|
continue
|
|
items = payload.get("items") or []
|
|
existing = {(item.get("source"), item.get("share_url")) for item in job["items"]}
|
|
for item in items:
|
|
unique_key = (item.get("source"), item.get("share_url"))
|
|
if unique_key in existing:
|
|
continue
|
|
existing.add(unique_key)
|
|
job["items"].append(item)
|
|
progress = payload.get("progress") or {}
|
|
job["progress"] = {
|
|
"processed_channels": progress.get("processed_channels", job["progress"]["processed_channels"]),
|
|
"total_channels": progress.get("total_channels", job["progress"]["total_channels"]),
|
|
"result_count": len(job["items"]),
|
|
}
|
|
job["message"] = payload.get("message") or f"已找到 {len(job['items'])} 条结果"
|
|
job["updated_at"] = time.time()
|
|
|
|
job["status"] = "completed"
|
|
job["message"] = f"搜索完成,共找到 {len(job['items'])} 条结果"
|
|
job["progress"]["result_count"] = len(job["items"])
|
|
job["updated_at"] = time.time()
|
|
except asyncio.CancelledError:
|
|
job["status"] = "cancelled"
|
|
job["message"] = "搜索已取消"
|
|
job["updated_at"] = time.time()
|
|
except Exception as e:
|
|
job["status"] = "failed"
|
|
job["error"] = str(e)
|
|
job["message"] = f"搜索失败: {e}"
|
|
job["updated_at"] = time.time()
|
|
|
|
def get_search_job(self, job_id: str) -> Dict[str, Any]:
|
|
job = self._search_jobs.get(job_id)
|
|
if not job:
|
|
raise KeyError(f"搜索任务不存在: {job_id}")
|
|
return self._serialize_search_job(job)
|
|
|
|
async def cancel_search_job(self, job_id: str) -> Dict[str, Any]:
|
|
job = self._search_jobs.get(job_id)
|
|
if not job:
|
|
raise KeyError(f"搜索任务不存在: {job_id}")
|
|
task = job.get("task")
|
|
if task and not task.done():
|
|
task.cancel()
|
|
job["status"] = "cancelled"
|
|
job["message"] = f"搜索已停止,当前保留 {len(job.get('items') or [])} 条结果"
|
|
job["updated_at"] = time.time()
|
|
progress = job.get("progress") or {}
|
|
progress["result_count"] = len(job.get("items") or [])
|
|
job["progress"] = progress
|
|
return self._serialize_search_job(job)
|
|
|
|
def _serialize_search_job(self, job: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"job_id": job["job_id"],
|
|
"plugin_id": job["plugin_id"],
|
|
"keyword": job["keyword"],
|
|
"page": job["page"],
|
|
"status": job["status"],
|
|
"items": job["items"],
|
|
"message": job["message"],
|
|
"error": job["error"],
|
|
"updated_at": job["updated_at"],
|
|
"progress": job["progress"],
|
|
}
|
|
|
|
|
|
plugin_manager = PluginManager()
|