fix(cli): align frontend download with version.py

Use FRONTEND_VERSION from version.py as the default frontend release target so local setup and auto-update install the matching frontend bundle.

Closes #5693
This commit is contained in:
jxxghp
2026-05-08 15:49:32 +08:00
parent 0dab3f087d
commit 52e15b51db
6 changed files with 223 additions and 26 deletions

View File

@@ -31,7 +31,6 @@ HEALTH_PATH = "/api/v1/system/global"
HEALTH_TOKEN = "moviepilot"
FRONTEND_HEALTH_PATH = "/version.txt"
BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
FRONTEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases"
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
MANAGED_ACTIVE_STATES = {"running", "starting"}
AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"}
@@ -279,9 +278,8 @@ def _auto_update_mode() -> str:
return SystemHelper.get_auto_update_mode()
def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str]]:
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
backend_prefix = _release_prefix(APP_VERSION)
frontend_prefix = _release_prefix(_installed_frontend_version() or APP_VERSION)
if mode == "dev":
current_branch = _git_current_branch()
@@ -295,13 +293,7 @@ def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str
repo="jxxghp/MoviePilot",
prefix=backend_prefix,
)
frontend_version = _latest_release_tag(
FRONTEND_RELEASES_API,
repo="jxxghp/MoviePilot-Frontend",
prefix=frontend_prefix,
)
return backend_ref, frontend_version
return backend_ref
def _best_effort_auto_update() -> None:
@@ -310,12 +302,12 @@ def _best_effort_auto_update() -> None:
return
try:
backend_ref, frontend_version = _resolve_auto_update_targets(mode)
backend_ref = _resolve_auto_update_targets(mode)
except RuntimeError as exc:
_warn(f"自动更新准备失败,继续使用当前版本启动:{exc}")
return
if not backend_ref or not frontend_version:
if not backend_ref:
_warn("自动更新准备失败,未能解析当前主版本对应的远端版本,继续使用当前版本启动")
return
@@ -326,8 +318,6 @@ def _best_effort_auto_update() -> None:
"all",
"--ref",
backend_ref,
"--frontend-version",
frontend_version,
"--venv",
str(_repo_root() / "venv"),
"--config-dir",

View File

@@ -14,7 +14,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- 自动检查并尽量安装 `git``curl``Python 3.11+`
- 克隆 `MoviePilot`
- 安装后端依赖
- 下载 `MoviePilot-Frontend` 最新 release 的 `dist.zip`
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
- 下载 `MoviePilot-Resources` 主分支资源
-`resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
- 下载本地 Node 运行时并安装前端运行依赖
@@ -172,7 +172,8 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config
说明:
- 默认下载 `MoviePilot-Frontend` 最新 release 的 `dist.zip`
- 默认按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
- 如需覆盖默认行为,仍可显式传入 `--version latest` 或指定具体 tag
- 会自动安装本地 Node 运行时
- 会自动安装 `service.js` 所需的运行依赖
@@ -323,8 +324,8 @@ moviepilot update all --skip-resources
说明:
- `update backend` 会更新 Git 仓库并重新安装后端依赖
- `update frontend` 会下载并替换前端 release
- `update all`同时更新后端前端,默认也会同步资源文件
- `update frontend`按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载并替换前端 release
- `update all`更新后端,再按更新后代码中的 `FRONTEND_VERSION` 更新前端,默认也会同步资源文件
- 更新前请先执行 `moviepilot stop`
## Agent 命令

View File

@@ -10,10 +10,10 @@ Usage: moviepilot [BOOTSTRAP COMMAND] | [RUNTIME COMMAND]
Bootstrap Commands:
moviepilot install deps [--python PYTHON] [--venv PATH] [--recreate] [--config-dir PATH]
moviepilot install frontend [--version latest] [--node-version 20.12.1] [--config-dir PATH]
moviepilot install frontend [--version TAG] [--node-version 20.12.1] [--config-dir PATH]
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version TAG] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot uninstall [--venv PATH] [--config-dir PATH]
moviepilot update {backend|frontend|all} [OPTIONS]
moviepilot startup {enable|disable|status} [--venv PATH] [--config-dir PATH]
@@ -107,7 +107,7 @@ Options:
--config-dir PATH 指定配置目录
frontend:
--version TAG 前端版本,默认 latest
--version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
--node-version VER 本地 Node 运行时版本,默认 20.12.1
--config-dir PATH 指定配置目录
@@ -143,7 +143,7 @@ Options:
--python PYTHON 用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.11+ 版本
--venv PATH 虚拟环境目录,默认 ./venv
--recreate 删除并重建虚拟环境
--frontend-version TAG 前端版本,默认 latest
--frontend-version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
--node-version VER 本地 Node 运行时版本,默认 20.12.1
--skip-resources 跳过资源同步
--force-token 强制重置 API_TOKEN
@@ -180,7 +180,7 @@ Usage:
Options:
--ref REF 后端 Git 版本,默认 latest
--frontend-version TAG 前端版本,默认 latest
--frontend-version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
--node-version VER 本地 Node 运行时版本,默认 20.12.1
--python PYTHON 用于安装后端依赖的 Python 解释器,默认自动选择本地 3.11+ 版本
--venv PATH 虚拟环境目录,默认 ./venv

View File

@@ -113,6 +113,21 @@ RUNTIME_PACKAGE = {
"express-http-proxy": "^2.0.0",
},
}
def _repo_frontend_version() -> str:
version_file = ROOT / "version.py"
module_name = f"moviepilot_version_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, version_file)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载版本文件:{version_file}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
frontend_version = str(getattr(module, "FRONTEND_VERSION", "") or "").strip()
if not frontend_version:
raise RuntimeError(f"版本文件未定义有效的 FRONTEND_VERSION{version_file}")
return frontend_version
LOCAL_FRONTEND_SERVICE_SCRIPT = textwrap.dedent(
"""
const http = require('node:http')
@@ -687,6 +702,7 @@ def _remove_path(path: Path) -> None:
def _resolve_frontend_release(frontend_version: str) -> tuple[str, str]:
frontend_version = (frontend_version or "").strip() or _repo_frontend_version()
if frontend_version == "latest":
release = fetch_json(FRONTEND_LATEST_API)
else:
@@ -3482,7 +3498,7 @@ def build_parser() -> argparse.ArgumentParser:
"install-frontend", help="下载前端 release 并安装本地运行时"
)
frontend_parser.add_argument(
"--version", default="latest", help="前端版本,默认 latest"
"--version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION"
)
frontend_parser.add_argument(
"--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本"
@@ -3535,7 +3551,7 @@ def build_parser() -> argparse.ArgumentParser:
"--recreate", action="store_true", help="删除并重建虚拟环境"
)
setup_parser.add_argument(
"--frontend-version", default="latest", help="前端版本,默认 latest"
"--frontend-version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION"
)
setup_parser.add_argument(
"--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本"
@@ -3592,7 +3608,7 @@ def build_parser() -> argparse.ArgumentParser:
"--ref", default="latest", help="后端 Git 版本,默认 latest"
)
update_parser.add_argument(
"--frontend-version", default="latest", help="前端版本,默认 latest"
"--frontend-version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION"
)
update_parser.add_argument(
"--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本"

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
import uuid
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
class _DummySystemHelper:
@staticmethod
def consume_one_shot_update_mode():
return None
@staticmethod
def get_auto_update_mode():
return "false"
def load_cli_module():
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
settings = SimpleNamespace(
TEMP_PATH=root / "temp",
LOG_PATH=root / "logs",
ROOT_PATH=root,
FRONTEND_PATH=str(root / "public"),
CONFIG_PATH=root / "config",
HOST="127.0.0.1",
PORT=3001,
NGINX_PORT=3000,
PROXY_HOST="",
GITHUB_TOKEN="",
PROXY={},
REPO_GITHUB_HEADERS=lambda _repo: {},
)
app_module = ModuleType("app")
core_module = ModuleType("app.core")
helper_module = ModuleType("app.helper")
config_module = ModuleType("app.core.config")
system_module = ModuleType("app.helper.system")
version_module = ModuleType("version")
psutil_module = ModuleType("psutil")
app_module.__path__ = []
core_module.__path__ = []
helper_module.__path__ = []
config_module.Settings = type("Settings", (), {})
config_module.settings = settings
system_module.SystemHelper = _DummySystemHelper
version_module.APP_VERSION = "v2.10.11"
psutil_module.STATUS_ZOMBIE = "zombie"
psutil_module.NoSuchProcess = RuntimeError
psutil_module.AccessDenied = RuntimeError
psutil_module.ZombieProcess = RuntimeError
psutil_module.Process = object
stub_modules = {
"app": app_module,
"app.core": core_module,
"app.helper": helper_module,
"app.core.config": config_module,
"app.helper.system": system_module,
"version": version_module,
"psutil": psutil_module,
}
module_name = f"moviepilot_app_cli_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
with patch.dict(sys.modules, stub_modules):
spec.loader.exec_module(module)
return module
class CliAutoUpdateTests(unittest.TestCase):
def test_resolve_auto_update_targets_only_queries_backend_release(self):
module = load_cli_module()
with patch.object(module, "_latest_release_tag", return_value="v2.10.12") as latest_mock:
backend_ref = module._resolve_auto_update_targets("release")
latest_mock.assert_called_once_with(
module.BACKEND_RELEASES_API,
repo="jxxghp/MoviePilot",
prefix="v2",
)
self.assertEqual(backend_ref, "v2.10.12")
def test_best_effort_auto_update_does_not_pass_frontend_version_override(self):
module = load_cli_module()
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
module, "_resolve_auto_update_targets", return_value="v2.10.12"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
command = run_mock.call_args.args[0]
self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"])
self.assertNotIn("--frontend-version", command)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
import uuid
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
module_name = f"moviepilot_local_setup_frontend_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class LocalSetupFrontendVersionTests(unittest.TestCase):
def test_repo_frontend_version_reads_version_file(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
(root / "version.py").write_text(
"APP_VERSION = 'v0.0.1'\nFRONTEND_VERSION = 'v9.9.9'\n",
encoding="utf-8",
)
with patch.object(module, "ROOT", root):
self.assertEqual(module._repo_frontend_version(), "v9.9.9")
def test_resolve_frontend_release_uses_repo_frontend_version_by_default(self):
module = load_local_setup_module()
release = {
"tag_name": "v9.9.9",
"assets": [
{
"name": "dist.zip",
"browser_download_url": "https://example.com/dist.zip",
}
],
}
with patch.object(module, "_repo_frontend_version", return_value="v9.9.9"), patch.object(
module, "fetch_json", return_value=release
) as fetch_mock:
tag_name, download_url = module._resolve_frontend_release(None)
fetch_mock.assert_called_once_with(
module.FRONTEND_TAG_API.format(tag="v9.9.9")
)
self.assertEqual(tag_name, "v9.9.9")
self.assertEqual(download_url, "https://example.com/dist.zip")
def test_parser_leaves_frontend_version_empty_until_runtime_resolution(self):
module = load_local_setup_module()
parser = module.build_parser()
install_args = parser.parse_args(["install-frontend"])
setup_args = parser.parse_args(["setup"])
update_args = parser.parse_args(["update", "frontend"])
self.assertIsNone(install_args.version)
self.assertIsNone(setup_args.frontend_version)
self.assertIsNone(update_args.frontend_version)
if __name__ == "__main__":
unittest.main()