Files
MusicServer/scripts/serve.py
T
snowy-yang bcfc1022eb feat(server): 平台默认组合——Windows granian+winloop,Unix granian+rloop
- granian[reload,rloop,uvloop,winloop] 改为默认依赖,uv sync 即具备完整启动栈
- auto 在启动器内解析为具体循环:granian Windows→winloop / Unix→rloop(缺省回退 uvloop/asyncio);
  uvicorn Unix→uvloop / Windows→asyncio
- 启动日志打印实际 server+loop;测试覆盖平台默认与回退;README 默认矩阵更新
2026-10-23 22:23:30 +08:00

195 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""启动器 —— 默认 granian + 平台首选事件循环(Windows: winloop / Unix: rloop)。
用法(在仓库根目录执行):
uv run python scripts/serve.py # Windows: granian+winloop;Unix: granian+rloop
uv run python scripts/serve.py --server uvicorn # Unix 默认 uvloop,Windows 默认 asyncio
uv run python scripts/serve.py --loop rloop # 显式指定(平台/安装不支持时直接报错)
uv run python scripts/serve.py --loop uvloop
uv run python scripts/serve.py --loop winloop
事件循环可用性(与依赖的平台 marker 一致):
asyncio 始终可用
rloop rloop 包,仅 Unix(Rust 事件循环)
uvloop uvloop 包,仅 Unix
winloop winloop 包,仅 Windows(uvloop 的 Windows 实现)
`auto` 在启动器内解析为具体循环,不交给服务器:
granian: Windows → winloop → asyncio;Unix → rloop → uvloop → asyncio
uvicorn: Windows → asyncio;Unix → 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"}
IS_WINDOWS = sys.platform.startswith("win")
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(uv sync 已包含);未安装时回退 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 default_loop(server: str) -> str:
"""平台默认循环:granian = Windows winloop / Unix rloop;uvicorn = Unix uvloop / Windows asyncio。
依赖未安装(例如只用 uvloop 时没装 rloop)则按优先级回退,直到 asyncio。
"""
if server == "granian":
order = ("winloop", "asyncio") if IS_WINDOWS else ("rloop", "uvloop", "asyncio")
else:
order = ("asyncio",) if IS_WINDOWS else ("uvloop", "asyncio")
for loop in order:
if loop == "asyncio":
return loop
package = LOOP_PACKAGES.get(loop)
if package and has_module(package):
return loop
return "asyncio"
def resolve_loop(server: str, loop: str) -> str:
"""auto → 平台默认循环;显式请求不可用时给出可操作的原因(不做静默降级)。"""
if loop not in LOOPS:
raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})")
if loop == "auto":
return default_loop(server)
if server == "uvicorn":
if loop == "asyncio":
return loop
if loop == "uvloop":
if not has_module("uvloop"):
raise SystemExit("uvloop 未安装(仅 Unix 可用):uv sync 或改用 --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 已包含)", 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)
requested_server = str(_arg_or_env(args.server, "SERVER", "auto"))
server = pick_server(requested_server)
if not has_module(server):
raise SystemExit(f"未安装 {server}:uv sync(或 uv add {server})")
if requested_server == "auto" and server == "uvicorn" and not has_module("granian"):
print("[serve] 未安装 granian,回退 uvicorn;uv sync 可安装默认的 granian 依赖", flush=True)
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())