import os import sys def _prepare_direct_execution_import_path() -> None: """ 修正直接执行 ``app/main.py`` 时的模块搜索路径。 PyCharm 的脚本启动方式会把 ``app`` 目录放到 ``sys.path[0]``,使应用内部 目录可能被当成顶级模块并遮蔽标准库或第三方包。直接执行时只保留项目根目录 作为应用导入入口,模块方式启动则不做任何调整。 """ if __package__: return script_dir = os.path.dirname(os.path.realpath(__file__)) project_root = os.path.dirname(script_dir) sys.path[:] = [ entry for entry in sys.path if os.path.realpath(entry or os.curdir) != script_dir ] if project_root in sys.path: sys.path.remove(project_root) sys.path.insert(0, project_root) _prepare_direct_execution_import_path() import setproctitle import signal import threading from pathlib import Path from typing import Optional import uvicorn as uvicorn from PIL import Image from uvicorn import Config from app.adapters.system.stdio import configure_rotating_stdio from app.adapters.system.host import SystemUtils stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE") if stdio_log_file: # 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。 configure_rotating_stdio( log_file=Path(stdio_log_file), max_bytes=max(int(os.getenv("MOVIEPILOT_STDIO_LOG_MAX_BYTES", "0") or 0), 1), backup_count=max( int(os.getenv("MOVIEPILOT_STDIO_LOG_BACKUP_COUNT", "0") or 0), 0, ), ) elif SystemUtils.is_frozen(): sys.stdout = open(os.devnull, 'w') sys.stderr = open(os.devnull, 'w') from app.factory import app from app.runtime.config import global_vars from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() from app.runtime.topology import ( UnsupportedProcessTopologyError, validate_process_topology, ) setproctitle.setproctitle(settings.PROJECT_NAME) class MoviePilotServer(uvicorn.Server): """在 Uvicorn 开始优雅退出前发布应用协作停止标志""" def handle_exit(self, sig, frame) -> None: global_vars.stop_system() super().handle_exit(sig, frame) APP_FACTORY = "app.factory:create_app" Server: Optional[MoviePilotServer] = None def create_server() -> MoviePilotServer: """创建不带 reload/multiprocess supervisor 的单进程生产服务器。""" server = MoviePilotServer( Config( app, host=settings.HOST, port=settings.PORT, reload=False, workers=1, timeout_graceful_shutdown=60, ) ) # 数据库准备阶段收到的信号早于 Server 物化,创建后必须继承既有停止意图。 if global_vars.is_system_stopped: server.should_exit = True return server def run_api_server() -> None: """按开发 reload、安全模式多进程或生产单进程选择 Uvicorn 入口。""" global Server supervised = settings.DEV or settings.API_WORKERS > 1 if supervised: if settings.DEV and settings.API_WORKERS > 1: raise UnsupportedProcessTopologyError( "Uvicorn reload 与多 worker 不能同时启用;" "开发模式请设置 API_WORKERS=1。" ) Server = None uvicorn.run( APP_FACTORY, factory=True, host=settings.HOST, port=settings.PORT, reload=settings.DEV, workers=settings.API_WORKERS, timeout_graceful_shutdown=60, ) return Server = create_server() Server.run() def request_shutdown() -> None: """发布协作停止标志并请求 Uvicorn 退出""" global_vars.stop_system() if Server is not None: Server.should_exit = True def start_tray(): """ 启动托盘图标 """ if not SystemUtils.is_frozen(): return if not SystemUtils.is_windows(): return def open_web(): """ 调用浏览器打开前端页面 """ import webbrowser webbrowser.open(f"http://localhost:{settings.NGINX_PORT}") def quit_app(): """ 退出程序 """ request_shutdown() TrayIcon.stop() import pystray TrayIcon = pystray.Icon( settings.PROJECT_NAME, icon=Image.open(settings.ROOT_PATH / 'app.ico'), menu=pystray.Menu( pystray.MenuItem( '打开', open_web, ), pystray.MenuItem( '退出', quit_app, ) ) ) threading.Thread(target=TrayIcon.run, daemon=True).start() def signal_handler(signum, frame): """ 信号处理函数,用于优雅停止服务 """ print(f"收到信号 {signum},开始优雅停止服务...") request_shutdown() def run_application() -> None: """初始化进程并启动 API 服务""" validate_process_topology( workers=settings.API_WORKERS, safe_mode=settings.MOVIEPILOT_SAFE_MODE, ) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) start_tray() run_api_server() if __name__ == '__main__': run_application()