Files
MoviePilot/app/adapters/web/plugin/routes.py
T

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)}"
)