mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-10 17:42:45 +08:00
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:
18
app/cli.py
18
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",
|
||||
|
||||
@@ -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 命令
|
||||
|
||||
10
moviepilot
10
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
|
||||
|
||||
@@ -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 运行时版本"
|
||||
|
||||
116
tests/test_cli_auto_update.py
Normal file
116
tests/test_cli_auto_update.py
Normal 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()
|
||||
74
tests/test_local_setup_frontend_version.py
Normal file
74
tests/test_local_setup_frontend_version.py
Normal 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()
|
||||
Reference in New Issue
Block a user