Skip to content

Commit

Permalink
Merge pull request #2774 from InfinityPacer/feature/api
Browse files Browse the repository at this point in the history
feat(api): add support for dynamic plugin APIs
  • Loading branch information
jxxghp authored Sep 25, 2024
2 parents 9863c85 + 222991d commit f0464c4
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 217 deletions.
95 changes: 72 additions & 23 deletions app/api/endpoints/plugin.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,93 @@
from typing import Any, List, Annotated
from typing import Any, List, Annotated, Optional

from fastapi import APIRouter, Depends, Header

from app import schemas
from app.factory import app
from app.core.config import settings
from app.core.plugin import PluginManager
from app.core.security import verify_token, verify_apikey
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser
from app.helper.plugin import PluginHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey

PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}

PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"

router = APIRouter()


def register_plugin_api(plugin_id: str = None):
def register_plugin_api(plugin_id: Optional[str] = None):
"""
注册插件API(先删除后新增)
动态注册插件 API
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
# 检查是否允许匿名访问,如果不允许匿名访问,则添加 API_TOKEN 验证
allow_anonymous = api.pop("allow_anonymous", False)
if not allow_anonymous:
api.setdefault("dependencies", []).append(Depends(verify_apikey))
router.add_api_route(**api)
_update_plugin_api_routes(plugin_id, action="add")


def remove_plugin_api(plugin_id: str):
"""
移除插件API
动态移除插件 API
:param plugin_id: 插件 ID
"""
_update_plugin_api_routes(plugin_id, action="remove")


def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
"""
插件 API 路由注册和移除
:param plugin_id: 插件 ID,如果为 None,则处理所有插件
:param action: 'add' 或 'remove',决定是添加还是移除路由
"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")

is_modified = False
existing_paths = {route.path: route for route in app.routes}
plugin_apis = PluginManager().get_plugin_apis(plugin_id)

for api in plugin_apis:
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
try:
existing_route = existing_paths.get(api_path)
if existing_route:
app.routes.remove(existing_route)
is_modified = True

if action == "add":
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous and Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
is_modified = True

except Exception as e:
logger.error(f"Error {action}ing route {api_path}: {str(e)}")

if is_modified:
_clean_protected_routes(existing_paths)
app.openapi_schema = None
app.setup()


def _clean_protected_routes(existing_paths: dict):
"""
清理受保护的路由,防止在插件操作中被删除或重复添加
:param existing_paths: 当前应用的路由路径映射
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
for protected_route in PROTECTED_ROUTES:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
app.routes.remove(existing_route)
except Exception as e:
logger.error(f"Error removing protected route {protected_route}: {str(e)}")


@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
Expand Down Expand Up @@ -247,12 +296,12 @@ def uninstall_plugin(plugin_id: str,
break
# 保存
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 移除插件
PluginManager().remove_plugin(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
# 移除插件API
remove_plugin_api(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
# 移除插件
PluginManager().remove_plugin(plugin_id)
return schemas.Response(success=True)


Expand Down
31 changes: 31 additions & 0 deletions app/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.core.config import settings
from app.startup.lifecycle import lifespan


def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例。
"""
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)

# 配置 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

return app


# 创建 FastAPI 应用实例
app = create_app()
197 changes: 3 additions & 194 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,128 +1,28 @@
import multiprocessing
import os
import signal
import sys
import threading
from contextlib import asynccontextmanager
from types import FrameType

import uvicorn as uvicorn
from PIL import Image
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config

from app.factory import app
from app.utils.system import SystemUtils

# 禁用输出
if SystemUtils.is_frozen():
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')

from app.core.config import settings, global_vars
from app.core.module import ModuleManager

# SitesHelper涉及资源包拉取,提前引入并容错提示
try:
from app.helper.sites import SitesHelper
except ImportError as e:
error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源"
print(error_message, file=sys.stderr)
sys.exit(1)

from app.core.event import EventManager
from app.core.plugin import PluginManager
from app.core.config import settings
from app.db.init import init_db, update_db
from app.helper.thread import ThreadHelper
from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.monitor import Monitor
from app.command import Command, CommandChian
from app.schemas import Notification, NotificationType


@asynccontextmanager
async def lifespan(app: FastAPI):
try:
print("Starting up...")
start_module()
yield
finally:
print("Shutting down...")
shutdown_server()


# App
App = FastAPI(title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan)

# 跨域
App.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# uvicorn服务
Server = uvicorn.Server(Config(App, host=settings.HOST, port=settings.PORT,
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
reload=settings.DEV, workers=multiprocessing.cpu_count()))


def init_routers():
"""
初始化路由
"""
from app.api.apiv1 import api_router
from app.api.servarr import arr_router
from app.api.servcookie import cookie_router
# API路由
App.include_router(api_router, prefix=settings.API_V1_STR)
# Radarr、Sonarr路由
App.include_router(arr_router, prefix="/api/v3")
# CookieCloud路由
App.include_router(cookie_router, prefix="/cookiecloud")


def start_frontend():
"""
启动前端服务
"""
# 仅Windows可执行文件支持内嵌nginx
if not SystemUtils.is_frozen() \
or not SystemUtils.is_windows():
return
# 临时Nginx目录
nginx_path = settings.ROOT_PATH / 'nginx'
if not nginx_path.exists():
return
# 配置目录下的Nginx目录
run_nginx_dir = settings.CONFIG_PATH.with_name('nginx')
if not run_nginx_dir.exists():
# 移动到配置目录
SystemUtils.move(nginx_path, run_nginx_dir)
# 启动Nginx
import subprocess
subprocess.Popen("start nginx.exe",
cwd=run_nginx_dir,
shell=True)


def stop_frontend():
"""
停止前端服务
"""
if not SystemUtils.is_frozen() \
or not SystemUtils.is_windows():
return
import subprocess
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)


def start_tray():
"""
启动托盘图标
Expand Down Expand Up @@ -169,97 +69,6 @@ def quit_app():
threading.Thread(target=TrayIcon.run, daemon=True).start()


def check_auth():
"""
检查认证状态
"""
if SitesHelper().auth_level < 2:
err_msg = "用户认证失败,站点相关功能将无法使用!"
MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system")
CommandChian().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证",
text=err_msg,
link=settings.MP_DOMAIN('#/site')
)
)


def singal_handle():
"""
监听停止信号
"""

def stop_event(signum: int, _: FrameType):
"""
SIGTERM信号处理
"""
print(f"接收到停止信号:{signum},正在停止系统...")
global_vars.stop_system()

# 设置信号处理程序
signal.signal(signal.SIGTERM, stop_event)
signal.signal(signal.SIGINT, stop_event)


def shutdown_server():
"""
服务关闭
"""
# 停止模块
ModuleManager().stop()
# 停止插件
PluginManager().stop()
PluginManager().stop_monitor()
# 停止事件消费
EventManager().stop()
# 停止虚拟显示
DisplayHelper().stop()
# 停止定时服务
Scheduler().stop()
# 停止监控
Monitor().stop()
# 停止线程池
ThreadHelper().shutdown()
# 停止前端服务
stop_frontend()


def start_module():
"""
启动模块
"""
# 虚拟显示
DisplayHelper()
# 站点管理
SitesHelper()
# 资源包检测
ResourceHelper()
# 加载模块
ModuleManager()
# 启动事件消费
EventManager().start()
# 安装在线插件
PluginManager().sync()
# 加载插件
PluginManager().start()
# 启动监控任务
Monitor()
# 启动定时服务
Scheduler()
# 加载命令
Command()
# 初始化路由
init_routers()
# 启动前端服务
start_frontend()
# 检查认证状态
check_auth()
# 监听停止信号
singal_handle()


if __name__ == '__main__':
# 启动托盘
start_tray()
Expand Down
Empty file added app/startup/__init__.py
Empty file.
Loading

0 comments on commit f0464c4

Please sign in to comment.