mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 21:17:06 +08:00
179 lines
7.1 KiB
Python
179 lines
7.1 KiB
Python
"""FastAPI 动态插件路由适配器。"""
|
|
|
|
import asyncio
|
|
from concurrent.futures import Future, TimeoutError as FutureTimeoutError
|
|
from threading import Lock
|
|
from typing import Any, Callable, Optional
|
|
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.routing import APIRoute
|
|
|
|
|
|
class FastAPIDynamicRouteRegistry:
|
|
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
|
|
|
|
_dispatch_admission_timeout = 5.0
|
|
|
|
def __init__(
|
|
self,
|
|
app: FastAPI,
|
|
plugin_ids: Callable[[], list[str]],
|
|
plugin_apis: Callable[[str], list[dict]],
|
|
verify_token: Callable[..., Any],
|
|
verify_apikey: Callable[..., Any],
|
|
prefix: str,
|
|
protected_routes: set[str],
|
|
log: Any,
|
|
event_loop: Callable[[], asyncio.AbstractEventLoop | None] | None = None,
|
|
) -> None:
|
|
"""注入应用、插件投影、认证依赖和日志端口。"""
|
|
self._app = app
|
|
self._plugin_ids = plugin_ids
|
|
self._plugin_apis = plugin_apis
|
|
self._verify_token = verify_token
|
|
self._verify_apikey = verify_apikey
|
|
self._prefix = prefix
|
|
self._protected_routes = protected_routes
|
|
self._logger = log
|
|
self._event_loop = event_loop
|
|
|
|
def update(self, plugin_id: Optional[str], action: str) -> None:
|
|
"""在主事件循环中按插件生命周期新增或移除动态路由。"""
|
|
if self._event_loop is None:
|
|
self._update(plugin_id, action)
|
|
return
|
|
target_loop = self._event_loop()
|
|
if (
|
|
target_loop is None
|
|
or not target_loop.is_running()
|
|
or target_loop.is_closed()
|
|
):
|
|
raise RuntimeError("主事件循环未运行,无法更新插件动态路由")
|
|
try:
|
|
current_loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
current_loop = None
|
|
if current_loop is target_loop:
|
|
self._update(plugin_id, action)
|
|
return
|
|
|
|
completed: Future[None] = Future()
|
|
dispatch_lock = Lock()
|
|
dispatch_started = False
|
|
dispatch_abandoned = False
|
|
|
|
def apply_update() -> None:
|
|
"""在目标 loop 的单个回调中完成路由表与 OpenAPI 投影切换。"""
|
|
nonlocal dispatch_started
|
|
with dispatch_lock:
|
|
if dispatch_abandoned:
|
|
return
|
|
dispatch_started = True
|
|
try:
|
|
self._update(plugin_id, action)
|
|
except BaseException as error:
|
|
completed.set_exception(error)
|
|
else:
|
|
completed.set_result(None)
|
|
|
|
target_loop.call_soon_threadsafe(apply_update)
|
|
try:
|
|
completed.result(timeout=self._dispatch_admission_timeout)
|
|
except FutureTimeoutError as error:
|
|
with dispatch_lock:
|
|
if not dispatch_started:
|
|
dispatch_abandoned = True
|
|
raise RuntimeError(
|
|
"主事件循环未及时接收插件动态路由更新"
|
|
) from error
|
|
# 回调一旦开始便不可撤销,等待确定终态以免失败回滚后发生迟到写入。
|
|
completed.result()
|
|
|
|
def _update(self, plugin_id: Optional[str], action: str) -> None:
|
|
"""执行不可中断的路由表与 OpenAPI 投影更新。"""
|
|
if action not in {"add", "remove"}:
|
|
raise ValueError("Action must be 'add' or 'remove'")
|
|
|
|
modified = False
|
|
existing_paths = {
|
|
path: route
|
|
for route in self._app.routes
|
|
if (path := self._route_path(route)) is not None
|
|
}
|
|
plugin_ids = [plugin_id] if plugin_id else self._plugin_ids()
|
|
for current_id in plugin_ids:
|
|
if self.remove(current_id):
|
|
modified = True
|
|
if action != "add":
|
|
continue
|
|
for source_api in self._plugin_apis(current_id):
|
|
api = dict(source_api)
|
|
api["dependencies"] = list(source_api.get("dependencies") or ())
|
|
api_path = f"{self._prefix}{api.get('path', '')}"
|
|
try:
|
|
api["path"] = api_path
|
|
allow_anonymous = api.pop("allow_anonymous", False)
|
|
auth_mode = api.pop("auth", "apikey")
|
|
dependencies = api.setdefault("dependencies", [])
|
|
if not allow_anonymous:
|
|
if (
|
|
auth_mode == "bear"
|
|
and Depends(self._verify_token) not in dependencies
|
|
):
|
|
dependencies.append(Depends(self._verify_token))
|
|
elif Depends(self._verify_apikey) not in dependencies:
|
|
dependencies.append(Depends(self._verify_apikey))
|
|
# 插件 API 自行决定响应结构,不使用宿主统一 Response 路由。
|
|
api.setdefault("route_class_override", APIRoute)
|
|
self._app.router.add_api_route(**api, tags=["plugin"])
|
|
modified = True
|
|
self._logger.debug(f"Added plugin route: {api_path}")
|
|
except Exception as error:
|
|
self._logger.error(
|
|
f"Error adding plugin route {api_path}: {str(error)}"
|
|
)
|
|
if modified:
|
|
self.clean(existing_paths)
|
|
self._app.openapi_schema = None
|
|
self._app.setup()
|
|
|
|
def remove(self, plugin_id: str) -> bool:
|
|
"""移除指定插件前缀下的全部动态路由。"""
|
|
if not plugin_id:
|
|
return False
|
|
prefix = f"{self._prefix}/{plugin_id}/"
|
|
routes = [
|
|
route for route in self._app.routes
|
|
if (path := self._route_path(route)) is not None
|
|
and path.startswith(prefix)
|
|
]
|
|
removed = False
|
|
for route in routes:
|
|
try:
|
|
self._app.routes.remove(route)
|
|
removed = True
|
|
self._logger.debug(f"Removed plugin route: {self._route_path(route)}")
|
|
except Exception as error:
|
|
self._logger.error(
|
|
f"Error removing plugin route {self._route_path(route)}: {str(error)}"
|
|
)
|
|
return removed
|
|
|
|
@staticmethod
|
|
def _route_path(route: Any) -> Optional[str]:
|
|
"""返回公开路由路径,跳过 FastAPI 内部的无路径 include 包装器。"""
|
|
path = getattr(route, "path", None)
|
|
return path if isinstance(path, str) else None
|
|
|
|
def clean(self, existing_paths: dict) -> None:
|
|
"""清理 FastAPI 重建时可能重复的受保护文档路由。"""
|
|
for protected_route in self._protected_routes:
|
|
try:
|
|
existing_route = existing_paths.get(protected_route)
|
|
if existing_route:
|
|
self._app.routes.remove(existing_route)
|
|
except Exception as error:
|
|
self._logger.error(
|
|
f"Error removing protected route {protected_route}: {str(error)}"
|
|
)
|