Files
MoviePilot/app/main.py
T

199 lines
5.3 KiB
Python

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()