"""启动器 —— uvicorn / granian,可选事件循环 rloop / uvloop / winloop。 用法(在仓库根目录执行): uv run python scripts/serve.py # 默认:装了 granian 就优先 granian,否则 uvicorn uv run python scripts/serve.py --server granian # Rust HTTP 服务器 uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(granian[rloop]) uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix(granian[uvloop]) uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows(granian[winloop]) uv run python scripts/serve.py --server uvicorn --loop uvloop # uvicorn 在 Unix 上等价 事件循环可用性(与 granian 的 extras / 平台 marker 一致): asyncio 始终可用 rloop rloop 包,仅 Unix(granian 内置的 Rust 循环) uvloop uvloop 包,仅 Unix winloop winloop 包,仅 Windows(uvloop 的 Windows 实现) `auto` 交给服务器自行选择:granian 优先 rloop → uvloop → winloop → asyncio; uvicorn 优先 uvloop(已安装时)→ asyncio。 环境变量(参数优先):SERVER / LOOP / HOST / PORT / WORKERS / RELOAD。 PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。 """ from __future__ import annotations import argparse import importlib.util import os import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from app import __version__, config # noqa: E402 APP_TARGET = "app.main:app" SERVERS = ("auto", "uvicorn", "granian") LOOPS = ("auto", "asyncio", "rloop", "uvloop", "winloop") #: 每个循环对应的可导入包(asyncio 无依赖) LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"} def has_module(name: str) -> bool: """模块是否可导入(不实际导入,避免触发事件循环初始化)。""" try: return importlib.util.find_spec(name) is not None except (ImportError, ValueError): return False def pick_server(requested: str) -> str: """auto:安装了 granian 就用 granian,否则 uvicorn。""" if requested not in SERVERS: raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})") if requested != "auto": return requested return "granian" if has_module("granian") else "uvicorn" def available_loops(server: str) -> list[str]: """当前平台/安装状态下该服务器可用的事件循环(含 auto)。""" loops = ["auto", "asyncio"] if server == "granian": loops.extend(name for name in ("rloop", "uvloop", "winloop") if has_module(LOOP_PACKAGES[name])) elif has_module("uvloop"): loops.append("uvloop") return loops def resolve_loop(server: str, loop: str) -> str: """校验事件循环可用性;显式请求不可用时给出可操作的原因(不做静默降级)。""" if loop not in LOOPS: raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})") if loop == "auto": return loop if server == "uvicorn": if loop == "asyncio": return loop if loop == "uvloop": if not has_module("uvloop"): raise SystemExit("uvloop 未安装(仅 Unix 可用):uv sync --extra granian 或改用 --loop auto") return loop raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)") package = LOOP_PACKAGES.get(loop) if package and not has_module(package): hint = "仅 Windows 可用" if loop == "winloop" else "仅 Unix 可用" raise SystemExit( f"granian 的 {loop} 事件循环不可用({package} 未安装,{hint})。可用:{', '.join(available_loops(server))}" ) return loop def _env_bool(name: str) -> bool: return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") def build_command( server: str, *, host: str, port: int, workers: int, loop: str, reload: bool, ) -> list[str]: command = [sys.executable, "-m", server] if server == "granian": command += ["--interface", "asgi", "--host", host, "--port", str(port)] command += ["--workers", str(workers), "--loop", loop] if reload: command.append("--reload") if reload and not has_module("watchfiles"): print("[serve] 警告:granian --reload 需要 watchfiles(uv sync --extra granian 已包含)", flush=True) command.append(APP_TARGET) else: command += [APP_TARGET, "--host", host, "--port", str(port)] command += ["--workers", str(workers), "--loop", loop] if reload: command.append("--reload") return command def _arg_or_env(value: str | None, env_name: str, default: str | None = None) -> str | None: return value if value is not None else os.environ.get(env_name, default) def main(argv: list[str] | None = None) -> int: config.load_env() parser = argparse.ArgumentParser(description="MusicServer 启动器(uvicorn / granian)") parser.add_argument("--server", choices=SERVERS, help="HTTP 服务器(默认 auto:优先 granian)") parser.add_argument("--loop", choices=LOOPS, help="事件循环(默认 auto)") parser.add_argument("--host", help="监听地址(默认 0.0.0.0)") parser.add_argument("--port", type=int, help="端口(默认 PORT 或 16688)") parser.add_argument("--workers", type=int, help="worker 进程数(默认 1)") parser.add_argument("--reload", action="store_true", help="开发热重载(强制单 worker)") args = parser.parse_args(argv) server = pick_server(str(_arg_or_env(args.server, "SERVER", "auto"))) if not has_module(server): raise SystemExit(f"未安装 {server}:uv sync --extra granian(或 uv add {server})") loop = resolve_loop(server, str(_arg_or_env(args.loop, "LOOP", "auto"))) host = str(_arg_or_env(args.host, "HOST", "0.0.0.0")) port = args.port or config.port() workers = args.workers or int(os.environ.get("WORKERS") or 1) reload = args.reload or _env_bool("RELOAD") if reload and workers > 1: print(f"[serve] --reload 与多 worker 不兼容,workers 从 {workers} 改为 1", flush=True) workers = 1 command = build_command(server, host=host, port=port, workers=workers, loop=loop, reload=reload) print( f"[serve] MusicServer {__version__} | {server} | loop={loop} | http://{host}:{port} " f"| workers={workers}{' | reload' if reload else ''}", flush=True, ) try: return subprocess.call(command) except KeyboardInterrupt: # Ctrl+C 由子进程自行处理,父进程安静退出 return 0 if __name__ == "__main__": raise SystemExit(main())