From cf4c6b2d401434970c5bb8fadc723b51e5984630 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 25 Sep 2024 02:20:12 +0800 Subject: [PATCH 1/2] refactor(app): restructure project to avoid circular imports --- app/factory.py | 31 +++++ app/main.py | 197 +----------------------------- app/startup/__init__.py | 0 app/startup/lifecycle.py | 19 +++ app/startup/module_initializer.py | 152 +++++++++++++++++++++++ app/startup/routers.py | 18 +++ 6 files changed, 223 insertions(+), 194 deletions(-) create mode 100644 app/factory.py create mode 100644 app/startup/__init__.py create mode 100644 app/startup/lifecycle.py create mode 100644 app/startup/module_initializer.py create mode 100644 app/startup/routers.py diff --git a/app/factory.py b/app/factory.py new file mode 100644 index 00000000..78d43546 --- /dev/null +++ b/app/factory.py @@ -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() diff --git a/app/main.py b/app/main.py index 803c6094..f6de2180 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,13 @@ 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 # 禁用输出 @@ -19,110 +15,14 @@ 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(): """ 启动托盘图标 @@ -169,97 +69,6 @@ def start_tray(): 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() diff --git a/app/startup/__init__.py b/app/startup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/startup/lifecycle.py b/app/startup/lifecycle.py new file mode 100644 index 00000000..7bb0e7d3 --- /dev/null +++ b/app/startup/lifecycle.py @@ -0,0 +1,19 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.startup.module_initializer import start_modules, shutdown_modules +from app.startup.routers import init_routers + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + 定义应用的生命周期事件 + """ + print("Starting up...") + start_modules(app) + init_routers(app) + yield + print("Shutting down...") + shutdown_modules(app) diff --git a/app/startup/module_initializer.py b/app/startup/module_initializer.py new file mode 100644 index 00000000..608f76b0 --- /dev/null +++ b/app/startup/module_initializer.py @@ -0,0 +1,152 @@ +import signal +import sys +from types import FrameType + +from fastapi import FastAPI + +from app.core.config import settings, global_vars +from app.core.module import ModuleManager +from app.utils.system import SystemUtils + +# 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.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 + + +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 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_modules(app: FastAPI): + """ + 服务关闭 + """ + # 停止模块 + ModuleManager().stop() + # 停止插件 + PluginManager().stop() + PluginManager().stop_monitor() + # 停止事件消费 + EventManager().stop() + # 停止虚拟显示 + DisplayHelper().stop() + # 停止定时服务 + Scheduler().stop() + # 停止监控 + Monitor().stop() + # 停止线程池 + ThreadHelper().shutdown() + # 停止前端服务 + stop_frontend() + + +def start_modules(app: FastAPI): + """ + 启动模块 + """ + # 虚拟显示 + DisplayHelper() + # 站点管理 + SitesHelper() + # 资源包检测 + ResourceHelper() + # 加载模块 + ModuleManager() + # 启动事件消费 + EventManager().start() + # 安装在线插件 + PluginManager().sync() + # 加载插件 + PluginManager().start() + # 启动监控任务 + Monitor() + # 启动定时服务 + Scheduler() + # 加载命令 + Command() + # 启动前端服务 + start_frontend() + # 检查认证状态 + check_auth() + # 监听停止信号 + singal_handle() diff --git a/app/startup/routers.py b/app/startup/routers.py new file mode 100644 index 00000000..101b8603 --- /dev/null +++ b/app/startup/routers.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from app.core.config import settings + + +def init_routers(app: FastAPI): + """ + 初始化路由 + """ + 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") From 222991d07f20b2f5a1ce972399a4e990997f8436 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 25 Sep 2024 02:20:23 +0800 Subject: [PATCH 2/2] feat(api): add support for dynamic plugin APIs --- app/api/endpoints/plugin.py | 95 ++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 2dfdc14e..f280d413 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -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 """ - 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 + _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 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]) @@ -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)