From 52e15b51db16fc8dd5560d9a54b3f4543ef5265e Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 8 May 2026 15:49:32 +0800 Subject: [PATCH] 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 --- app/cli.py | 18 +--- docs/cli.md | 9 +- moviepilot | 10 +- scripts/local_setup.py | 22 +++- tests/test_cli_auto_update.py | 116 +++++++++++++++++++++ tests/test_local_setup_frontend_version.py | 74 +++++++++++++ 6 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 tests/test_cli_auto_update.py create mode 100644 tests/test_local_setup_frontend_version.py diff --git a/app/cli.py b/app/cli.py index 491cd817..2bb31047 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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", diff --git a/docs/cli.md b/docs/cli.md index 84da1041..3341d07a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 命令 diff --git a/moviepilot b/moviepilot index b5d98126..7e4cfdcd 100755 --- a/moviepilot +++ b/moviepilot @@ -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 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index affe3b6e..f9b8f36b 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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 运行时版本" diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py new file mode 100644 index 00000000..b501cd10 --- /dev/null +++ b/tests/test_cli_auto_update.py @@ -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() diff --git a/tests/test_local_setup_frontend_version.py b/tests/test_local_setup_frontend_version.py new file mode 100644 index 00000000..c494d0e2 --- /dev/null +++ b/tests/test_local_setup_frontend_version.py @@ -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()