diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index 2a1ceea76..c20cdccfe 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -1040,7 +1040,7 @@ "source_line": 195 }, "MOVIEPILOT_UPDATE_DEV": { - "description": "独立控制启动时跟踪 v3 开发分支。", + "description": "Docker 启动时跟踪开发分支;本地 CLI 仅在手动 update 时默认使用 DEV 模式。", "group": "system_update", "source_file": "app/runtime/config.py", "source_line": 457 diff --git a/app/cli.py b/app/cli.py index 3d54340eb..191c678a6 100644 --- a/app/cli.py +++ b/app/cli.py @@ -242,11 +242,11 @@ def _git_current_branch() -> Optional[str]: return branch or None -def _auto_update_mode() -> str: - """启动时仅由独立 Dev 开关或一次性更新请求选择开发分支。""" +def _requested_update_mode() -> str: + """仅消费用户明确发起的一次性 Dev 更新请求。""" if SystemHelper.consume_one_shot_dev_update(): return "dev" - return "dev" if get_runtime_setting("MOVIEPILOT_UPDATE_DEV") is True else "false" + return "false" def _file_sha256(path: Path) -> str: @@ -478,7 +478,8 @@ def _apply_prepared_release_update() -> bool: return True -def _resolve_auto_update_targets(mode: str) -> Optional[str]: +def _resolve_dev_update_target(mode: str) -> Optional[str]: + """跟踪当前开发分支,并为 Release 检出的 detached HEAD 选择主版本分支。""" if mode != "dev": return None backend_prefix = _release_prefix(get_app_version()) @@ -490,18 +491,18 @@ def _resolve_auto_update_targets(mode: str) -> Optional[str]: return backend_ref -def _best_effort_auto_update() -> None: - """优先应用已确认的安装包,再按 Dev 跟踪偏好更新;失败不阻断启动。""" +def _apply_requested_update() -> None: + """应用用户已确认的安装包或一次性 Dev 更新,失败后恢复当前服务。""" if _apply_prepared_release_update(): return - mode = _auto_update_mode() + mode = _requested_update_mode() # Release 更新先在后台下载并经用户确认;这里只保留开发版分支跟踪。 if mode != "dev": return try: - backend_ref = _resolve_auto_update_targets(mode) + backend_ref = _resolve_dev_update_target(mode) except RuntimeError as exc: _warn(f"自动更新准备失败,继续使用当前版本启动:{exc}") return @@ -515,6 +516,7 @@ def _best_effort_auto_update() -> None: str(_repo_root() / "scripts" / "local_setup.py"), "update", "all", + "--dev", "--ref", backend_ref, "--venv", @@ -523,7 +525,7 @@ def _best_effort_auto_update() -> None: str(get_runtime_setting("CONFIG_PATH")), ] - click.echo("检测到 Dev 跟踪开关或一次性更新请求,启动前执行本地开发版更新") + click.echo("执行已确认的本地开发版更新") result = subprocess.run( update_command, cwd=str(_repo_root()), @@ -1136,11 +1138,6 @@ def database_restore(name: str, confirm: bool) -> None: def start(timeout: int, safe: bool) -> None: """后台启动本地 MoviePilot 前后端服务""" _ensure_frontend_not_running_alone(timeout=min(timeout, 15)) - backend_state, _, _, _ = _managed_backend_status() - frontend_state, _, _, _ = _managed_frontend_status() - if backend_state == "stopped" and frontend_state == "stopped": - _best_effort_auto_update() - backend_result = _start_backend_service(timeout=timeout, safe=safe) backend_runtime = backend_result["runtime"] try: @@ -1201,11 +1198,13 @@ def stop(timeout: int, force: bool) -> None: @click.option("--start-timeout", default=60, show_default=True, help="重启后等待服务就绪的秒数") @click.option("--stop-timeout", default=30, show_default=True, help="停止服务时等待退出的秒数") @click.option("--force", is_flag=True, help="停止超时后强制结束进程") -def restart(start_timeout: int, stop_timeout: int, force: bool) -> None: +@click.option("--apply-update", is_flag=True, hidden=True) # type: ignore[misc] +def restart(start_timeout: int, stop_timeout: int, force: bool, apply_update: bool = False) -> None: """重启本地 MoviePilot 前后端服务""" _stop_frontend_service(timeout=stop_timeout, force=force) _stop_backend_service(timeout=stop_timeout, force=force) - _best_effort_auto_update() + if apply_update: + _apply_requested_update() backend_result = _start_backend_service(timeout=start_timeout) frontend_result = _start_frontend_service( timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]) diff --git a/app/runtime/config.py b/app/runtime/config.py index 93e5b77cb..fed959dbf 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -453,7 +453,7 @@ class ConfigModel(BaseModel): serialize=_serialize_bool, ), ] = False - # 独立控制启动时跟踪 v3 开发分支。 + # Docker 启动时跟踪开发分支;本地 CLI 仅在手动 update 时默认使用 DEV 模式。 MOVIEPILOT_UPDATE_DEV: Annotated[ bool, SettingPolicy(serialize=_serialize_bool) ] = False diff --git a/app/runtime/state.py b/app/runtime/state.py index 3a9f907b8..4851e94bd 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -148,10 +148,15 @@ class SystemHelper(ConfigReloadMixin): @staticmethod def _spawn_local_restart_helper() -> None: """启动脱离当前进程的本地 CLI 重启助手。""" + apply_update = ( + SystemHelper.__prepared_update_manifest.is_file() + or SystemHelper.__one_shot_dev_update_flag_file.is_file() + ) helper_code = ( "import os, subprocess, sys, time;" "time.sleep(1.0);" "cmd=[sys.executable, '-m', 'app.cli', 'restart', '--force', '--stop-timeout', '30', '--start-timeout', '60'];" + f"cmd.extend({['--apply-update'] if apply_update else []!r});" "subprocess.run(cmd, cwd=os.environ.get('MOVIEPILOT_ROOT'), env=os.environ.copy(), check=False)" ) env = os.environ.copy() diff --git a/docs/cli.md b/docs/cli.md index 1c1149e66..1c832b1f5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -345,7 +345,9 @@ moviepilot update frontend --frontend-version v3.0.0 整体更新: ```shell -moviepilot update all +moviepilot update +moviepilot update all --dev +moviepilot update all --no-dev moviepilot update all --ref latest --frontend-version latest moviepilot update all --skip-resources ``` @@ -353,8 +355,10 @@ moviepilot update all --skip-resources 说明: - `update backend` 会更新 Git 仓库并重新安装后端依赖,包括 `moviepilot-rust` 加速扩展 -- `update frontend` 会按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载并替换前端 release -- `update all` 会先更新后端,再按更新后代码中的 `FRONTEND_VERSION` 更新前端,默认也会同步资源文件 +- `MOVIEPILOT_UPDATE_DEV=true` 时,`moviepilot update` 默认使用 DEV 模式;`--dev` / `--no-dev` 可临时覆盖,省略目标时更新全部组件。进程环境变量优先于配置目录中的 `app.env` +- DEV 模式后端跟踪当前开发分支,处于 Release 的 detached HEAD 时回到 `v3`;前端下载最新 Release 的 `dist.zip`。显式 `--ref` / `--frontend-version` 优先于默认选择 +- 非 DEV 模式下,`update frontend` 会按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载并替换前端 release +- `update all` 会先更新后端,再按所选模式更新前端,默认也会同步资源文件 - 更新前请先执行 `moviepilot stop` ## Agent 命令 @@ -394,10 +398,10 @@ moviepilot version 说明: -- `start` 会先启动后端,再启动前端 +- `start` 会先启动后端,再启动前端;普通 `start` / `restart` 不检查、不下载、不安装更新,DEV 模式也不例外 - `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置 -- `MOVIEPILOT_AUTO_UPDATE` 为布尔开关,默认 `false`;只有 `true` 启用后台 Release 检查和版本提醒,保存后定时服务热更新。`AUTO_UPDATE_RESOURCE` 独立控制站点资源检查和提醒;任一开关开启即启用检测服务,且只检查对应目标,两者均关闭才移除服务。`MOVIEPILOT_UPDATE_DEV` 为独立布尔开关,默认 `false`;设为 `true` 时在每次启动/重启前跟踪当前 v3 开发分支,更新失败只告警,不阻断当前启动。旧 `dev/release` 值统一转换为 `MOVIEPILOT_AUTO_UPDATE=true`;旧 `dev` 在未显式配置新开关时迁移为 `MOVIEPILOT_UPDATE_DEV=true` -- Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,启动阶段只安装已下载且通过 SHA-256 校验的包 +- `MOVIEPILOT_AUTO_UPDATE` 为布尔开关,默认 `false`;只有 `true` 启用后台 Release 检查和版本提醒,保存后定时服务热更新。`AUTO_UPDATE_RESOURCE` 独立控制站点资源检查和提醒;任一开关开启即启用检测服务,且只检查对应目标,两者均关闭才移除服务。`MOVIEPILOT_UPDATE_DEV` 为独立布尔开关,默认 `false`;设为 `true` 时手动 `moviepilot update` 默认使用 DEV 更新模式。旧 `dev/release` 值统一转换为 `MOVIEPILOT_AUTO_UPDATE=true`;旧 `dev` 在未显式配置新开关时迁移为 `MOVIEPILOT_UPDATE_DEV=true` +- Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,通过专门的内部更新入口安装已下载且通过 SHA-256 校验的包后再启动服务 - 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示 - 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启 - 前端默认监听 `NGINX_PORT`,默认值 `3000` diff --git a/moviepilot b/moviepilot index 220794a4e..1a49e922c 100755 --- a/moviepilot +++ b/moviepilot @@ -15,7 +15,7 @@ Bootstrap Commands: moviepilot init [--skip-resources] [--force-token] [--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 update [backend|frontend|all] [OPTIONS] moviepilot startup {enable|disable|status} [--venv PATH] [--config-dir PATH] moviepilot agent [OPTIONS] MESSAGE... @@ -184,11 +184,12 @@ show_update_help() { Usage: moviepilot update backend [OPTIONS] moviepilot update frontend [OPTIONS] - moviepilot update all [OPTIONS] + moviepilot update [all] [OPTIONS] Options: - --ref REF 后端 Git 版本,默认 latest - --frontend-version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION + --ref REF 后端 Git 版本,默认当前分支;DEV 下 detached HEAD 回到 v3 + --dev / --no-dev 指定 DEV 更新模式,默认读取 MOVIEPILOT_UPDATE_DEV + --frontend-version TAG 前端版本,DEV 默认 latest,否则使用 FRONTEND_VERSION --node-version VER 本地 Node 运行时版本,默认 20.12.1 --python PYTHON 用于安装后端依赖的 Python 解释器,默认自动选择本地 3.14+ 版本 --venv PATH 虚拟环境目录,默认 ./venv diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 50de341f7..0bd2d8c52 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -3799,6 +3799,7 @@ def _ensure_git_clean() -> None: def _update_backend_ref(ref: str, *, fetch: bool = True) -> str: + """同步后端 Git 引用;分支快进到远端,离线标签不访问网络。""" if not (ROOT / ".git").exists(): raise RuntimeError("当前目录不是 Git 仓库,无法更新后端代码。") @@ -3825,9 +3826,29 @@ def _update_backend_ref(ref: str, *, fetch: bool = True) -> str: print_step(f"切换后端代码到指定版本:{ref}") run(["git", "checkout", ref], cwd=ROOT) + if fetch and _git_output("rev-parse", "--abbrev-ref", "HEAD") == ref: + run(["git", "pull", "--ff-only", "origin", ref], cwd=ROOT) return ref +def _resolve_update_versions(args: argparse.Namespace) -> tuple[str, Optional[str]]: + """更新命令读取 Dev 偏好;显式版本优先,离线安装始终使用已确认制品。""" + value = os.environ.get("MOVIEPILOT_UPDATE_DEV") + if value is None: + value = read_env_value("MOVIEPILOT_UPDATE_DEV") or "false" + dev = args.dev if args.dev is not None else value.strip().lower() in { + "1", "true", "yes", "y", "on" + } + dev = dev and not args.offline_backend + ref = args.ref + if not ref: + ref = "latest" + if dev and args.target in {"backend", "all"} and _git_output("rev-parse", "--abbrev-ref", "HEAD") == "HEAD": + ref = RESOURCE_VERSION_FLAG + frontend_version = args.frontend_version or ("latest" if dev else None) + return ref, frontend_version + + def update_backend( *, ref: str, @@ -3836,6 +3857,7 @@ def update_backend( recreate: bool, fetch: bool = True, ) -> Path: + """更新指定后端版本并同步其虚拟环境依赖。""" ensure_services_stopped() resolved_ref = _update_backend_ref(ref=ref, fetch=fetch) venv_python = install_deps( @@ -3920,6 +3942,7 @@ def run_agent_request( def build_parser() -> argparse.ArgumentParser: + """定义本地安装、显式更新和初始化命令的参数。""" parser = argparse.ArgumentParser(description="MoviePilot 本地安装与初始化工具") subparsers = parser.add_subparsers(dest="command", required=True) @@ -4049,13 +4072,17 @@ def build_parser() -> argparse.ArgumentParser: update_parser = subparsers.add_parser("update", help="更新本地后端、前端或全部组件") update_parser.add_argument( - "target", choices=["backend", "frontend", "all"], help="更新目标" + "target", nargs="?", default="all", choices=["backend", "frontend", "all"], help="更新目标,默认 all" ) update_parser.add_argument( - "--ref", default="latest", help="后端 Git 版本,默认 latest" + "--ref", help="后端 Git 版本,默认跟踪当前分支;DEV 模式下 detached HEAD 回到 v3" ) update_parser.add_argument( - "--frontend-version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION" + "--dev", action=argparse.BooleanOptionalAction, default=None, + help="使用 DEV 更新模式,默认读取 MOVIEPILOT_UPDATE_DEV;--no-dev 关闭" + ) + update_parser.add_argument( + "--frontend-version", help="前端版本,DEV 默认 latest,否则使用 version.py 中的 FRONTEND_VERSION" ) update_parser.add_argument( "--frontend-archive", @@ -4138,6 +4165,7 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: + """按选定配置目录执行本地管理命令并返回退出码。""" parser = build_parser() args = parser.parse_args() explicit_config_dir = ( @@ -4265,10 +4293,11 @@ def main() -> int: return 0 if args.command == "update": + ref, frontend_version = _resolve_update_versions(args) ensure_services_stopped() if args.target in {"backend", "all"}: update_backend( - ref=args.ref, + ref=ref, python_bin=args.python, venv_dir=Path(args.venv), recreate=args.recreate, @@ -4276,7 +4305,7 @@ def main() -> int: ) if args.target in {"frontend", "all"}: frontend_result = install_frontend( - frontend_version=args.frontend_version, + frontend_version=frontend_version, node_version=args.node_version, archive=Path(args.frontend_archive) if args.frontend_archive else None, ) diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py index 217bdbfe1..2d63f048d 100644 --- a/tests/test_cli_auto_update.py +++ b/tests/test_cli_auto_update.py @@ -16,8 +16,11 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py" class _DummySystemHelper: + """隔离一次性更新请求的系统状态。""" + @staticmethod def consume_one_shot_dev_update(): + """默认没有用户确认的更新请求。""" return False @@ -91,11 +94,12 @@ def load_cli_module(): return module -def test_resolve_auto_update_targets_keeps_dev_branch_tracking(): +def test_resolve_dev_update_target_keeps_dev_branch_tracking(): + """确认的 DEV 更新继续跟踪当前开发分支。""" module = load_cli_module() with patch.object(module, "_git_current_branch", return_value="v3"): - assert module._resolve_auto_update_targets("dev") == "latest" - assert module._resolve_auto_update_targets("release") is None + assert module._resolve_dev_update_target("dev") == "latest" + assert module._resolve_dev_update_target("release") is None def test_one_shot_dev_update_overrides_disabled_default(): @@ -106,29 +110,31 @@ def test_one_shot_dev_update_overrides_disabled_default(): with patch.object( module.SystemHelper, "consume_one_shot_dev_update", return_value=True ): - assert module._auto_update_mode() == "dev" + assert module._requested_update_mode() == "dev" @pytest.mark.parametrize("auto_update", [True, False]) @pytest.mark.parametrize("update_dev", [True, False]) def test_dev_tracking_is_independent_of_automatic_checks(auto_update, update_dev): - """检查开关不触发启动更新,Dev 开关单独选择开发分支。""" + """配置开关不能替代用户明确发起的更新请求。""" module = load_cli_module() module.settings.MOVIEPILOT_AUTO_UPDATE = auto_update module.settings.MOVIEPILOT_UPDATE_DEV = update_dev - assert module._auto_update_mode() == ("dev" if update_dev else "false") + assert module._requested_update_mode() == "false" def test_release_mode_does_not_update_during_start(): + """没有一次性 DEV 请求时不执行在线更新。""" module = load_cli_module() - with patch.object(module, "_auto_update_mode", return_value="release"), patch.object( + with patch.object(module, "_requested_update_mode", return_value="release"), patch.object( module.subprocess, "run" ) as run_mock: - module._best_effort_auto_update() + module._apply_requested_update() run_mock.assert_not_called() def test_prepared_release_uses_downloaded_package_before_dev_mode(): + """确认的离线安装包优先于一次性 DEV 更新。""" module = load_cli_module() module.PREPARED_UPDATE_ROOT.mkdir(parents=True) backend = module.PREPARED_UPDATE_ROOT / "backend.zip" @@ -150,10 +156,10 @@ def test_prepared_release_uses_downloaded_package_before_dev_mode(): ) run_result = SimpleNamespace(returncode=0, stdout="ok") - with patch.object(module, "_auto_update_mode", return_value="dev") as mode, patch.object( + with patch.object(module, "_requested_update_mode", return_value="dev") as mode, patch.object( module.subprocess, "run", return_value=run_result ) as run_mock, patch.object(module.click, "echo"): - module._best_effort_auto_update() + module._apply_requested_update() command = run_mock.call_args.args[0] assert "--offline-backend" in command @@ -163,6 +169,7 @@ def test_prepared_release_uses_downloaded_package_before_dev_mode(): def test_prepared_release_passes_package_env_and_overrides_proxy(): + """离线更新子进程继承缓存目录和当前代理。""" module = load_cli_module() module.settings.PROXY_HOST = "http://proxy.example:7890" module.settings.PIP_PROXY = "https://mirror.example/simple" @@ -202,39 +209,43 @@ def test_prepared_release_passes_package_env_and_overrides_proxy(): assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv") -def test_best_effort_auto_update_does_not_pass_frontend_version_override(): +def test_apply_requested_update_does_not_pass_frontend_version_override(): + """明确 DEV 模式由安装器解析最新前端 Release。""" module = load_cli_module() run_result = SimpleNamespace(returncode=0, stdout="ok") - with patch.object(module, "_auto_update_mode", return_value="dev"), patch.object( - module, "_resolve_auto_update_targets", return_value="latest" + with patch.object(module, "_requested_update_mode", return_value="dev"), patch.object( + module, "_resolve_dev_update_target", return_value="latest" ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( module.click, "echo" ): - module._best_effort_auto_update() + module._apply_requested_update() command = run_mock.call_args.args[0] - assert command[1:5] == [ + assert command[1:6] == [ str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", + "--dev", "--ref", ] assert "--frontend-version" not in command + assert "--dev" in command -def test_best_effort_auto_update_passes_package_env_and_overrides_proxy(): +def test_apply_requested_update_passes_package_env_and_overrides_proxy(): + """DEV 更新使用配置中的包缓存和代理。""" module = load_cli_module() module.settings.PROXY_HOST = "http://proxy.example:7890" module.settings.PIP_PROXY = "https://mirror.example/simple" run_result = SimpleNamespace(returncode=0, stdout="ok") with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object( - module, "_auto_update_mode", return_value="dev" - ), patch.object(module, "_resolve_auto_update_targets", return_value="latest"), patch.object( + module, "_requested_update_mode", return_value="dev" + ), patch.object(module, "_resolve_dev_update_target", return_value="latest"), patch.object( module.subprocess, "run", return_value=run_result ) as run_mock, patch.object(module.click, "echo"): - module._best_effort_auto_update() + module._apply_requested_update() env = run_mock.call_args.kwargs["env"] assert env["HTTPS_PROXY"] == "http://proxy.example:7890" @@ -243,7 +254,8 @@ def test_best_effort_auto_update_passes_package_env_and_overrides_proxy(): assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv") -def test_best_effort_auto_update_derives_tool_cache_from_existing_root(): +def test_apply_requested_update_derives_tool_cache_from_existing_root(): + """已有包缓存根目录优先于配置默认值。""" module = load_cli_module() run_result = SimpleNamespace(returncode=0, stdout="ok") package_cache_root = Path("/custom/package-cache-root") @@ -252,12 +264,12 @@ def test_best_effort_auto_update_derives_tool_cache_from_existing_root(): module.os.environ, {"PACKAGE_CACHE_ROOT": str(package_cache_root)}, clear=True, - ), patch.object(module, "_auto_update_mode", return_value="dev"), patch.object( - module, "_resolve_auto_update_targets", return_value="latest" + ), patch.object(module, "_requested_update_mode", return_value="dev"), patch.object( + module, "_resolve_dev_update_target", return_value="latest" ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( module.click, "echo" ): - module._best_effort_auto_update() + module._apply_requested_update() env = run_mock.call_args.kwargs["env"] assert env["PACKAGE_CACHE_ROOT"] == str(package_cache_root) @@ -265,6 +277,7 @@ def test_best_effort_auto_update_derives_tool_cache_from_existing_root(): def _prepared_resource_files(module): + """构造带摘要的离线资源包清单。""" resource_dir = module.PREPARED_UPDATE_ROOT / "resources" resource_dir.mkdir(parents=True, exist_ok=True) resources = [] @@ -285,6 +298,7 @@ def _prepared_resource_files(module): def test_prepared_resource_update_uses_offline_resource_install_only(): + """资源更新仅应用已校验的本地资源包。""" module = load_cli_module() module.PREPARED_UPDATE_ROOT.mkdir(parents=True, exist_ok=True) module.PREPARED_UPDATE_MANIFEST.write_text( @@ -306,6 +320,7 @@ def test_prepared_resource_update_uses_offline_resource_install_only(): def test_prepared_application_and_resources_install_in_order(): + """同时更新时先安装主程序再安装资源包。""" module = load_cli_module() module.PREPARED_UPDATE_ROOT.mkdir(parents=True, exist_ok=True) backend = module.PREPARED_UPDATE_ROOT / "backend.zip" @@ -339,3 +354,68 @@ def test_prepared_application_and_resources_install_in_order(): assert "update" in commands[0] assert "install-resources" in commands[1] assert not module.PREPARED_UPDATE_MANIFEST.exists() + + +@pytest.mark.parametrize("command", ["start", "restart"]) +@pytest.mark.parametrize("dev", [False, True]) +def test_service_start_never_applies_updates(command, dev): + """普通启动和重启均不能读取更新请求或执行安装,即使开启 DEV。""" + from click.testing import CliRunner + + module = load_cli_module() + module.settings.MOVIEPILOT_UPDATE_DEV = dev + result = { + "runtime": {"port": 3001}, "process": SimpleNamespace(pid=123), + "started": True, "health": {}, + } + with patch.object(module, "_ensure_frontend_not_running_alone"), patch.object( + module, "_start_backend_service", return_value=result + ), patch.object(module, "_start_frontend_service", return_value=result), patch.object( + module, "_stop_backend_service" + ), patch.object(module, "_stop_frontend_service"), patch.object( + module, "_apply_requested_update" + ) as update: + response = CliRunner().invoke(module.cli, [command]) + assert response.exit_code == 0, response.output + update.assert_not_called() + + +def test_confirmed_update_runs_between_stop_and_start(): + """系统确认的升级通过显式内部入口执行,安装完成后再启动服务。""" + from click.testing import CliRunner + + module = load_cli_module() + calls = [] + with patch.object(module, "_stop_frontend_service", side_effect=lambda **_: calls.append("stop_frontend")), patch.object( + module, "_stop_backend_service", side_effect=lambda **_: calls.append("stop_backend") + ), patch.object(module, "_apply_requested_update", side_effect=lambda: calls.append("update")), patch.object( + module, "_start_backend_service", side_effect=lambda **_: calls.append("start_backend") or {"runtime": {"port": 3001}} + ), patch.object(module, "_start_frontend_service", return_value={"runtime": {"port": 3000}}): + response = CliRunner().invoke(module.cli, ["restart", "--apply-update"]) + assert response.exit_code == 0, response.output + assert calls == ["stop_frontend", "stop_backend", "update", "start_backend"] + + +@pytest.mark.parametrize("pending", [None, "release", "dev"]) +def test_restart_helper_only_applies_confirmed_updates(monkeypatch, tmp_path, pending): + """内部重启助手只在明确的升级清单或一次性请求存在时传递更新选项。""" + import subprocess + import time + + from app.runtime.state import SystemHelper + + for attribute, name in ( + ("_SystemHelper__prepared_update_manifest", "release"), + ("_SystemHelper__one_shot_dev_update_flag_file", "dev"), + ("_SystemHelper__local_restart_log_file", "restart.log"), + ): + path = tmp_path / name + monkeypatch.setattr(SystemHelper, attribute, path) + if pending == name: + path.touch() + with patch.object(subprocess, "Popen", return_value=SimpleNamespace(pid=123)) as spawn: + SystemHelper._spawn_local_restart_helper() + helper_code = spawn.call_args.args[0][2] + with patch.object(time, "sleep"), patch.object(subprocess, "run") as run: + exec(helper_code, {}) + assert ("--apply-update" in run.call_args.args[0]) is (pending is not None) diff --git a/tests/test_local_setup_frontend_version.py b/tests/test_local_setup_frontend_version.py index b3de69f69..95c9d38df 100644 --- a/tests/test_local_setup_frontend_version.py +++ b/tests/test_local_setup_frontend_version.py @@ -2,7 +2,7 @@ from __future__ import annotations import importlib.util import tempfile -import unittest +import pytest import uuid from pathlib import Path from unittest.mock import patch @@ -12,6 +12,7 @@ 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) @@ -20,51 +21,134 @@ def load_local_setup_module(): return module -class LocalSetupFrontendVersionTests(unittest.TestCase): - def test_repo_frontend_version_reads_version_file(self): - module = load_local_setup_module() +def test_repo_frontend_version_reads_version_file(): + """前端固定版本取自当前源码声明。""" + 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") + 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", ) - 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() + with patch.object(module, "ROOT", root): + assert module._repo_frontend_version() == "v9.9.9" - install_args = parser.parse_args(["install-frontend"]) - setup_args = parser.parse_args(["setup"]) - update_args = parser.parse_args(["update", "frontend"]) +def test_resolve_frontend_release_uses_repo_frontend_version_by_default(): + """普通模式请求源码声明的前端 Release。""" + module = load_local_setup_module() + release = { + "tag_name": "v9.9.9", + "assets": [ + { + "name": "dist.zip", + "browser_download_url": "https://example.com/dist.zip", + } + ], + } - self.assertIsNone(install_args.version) - self.assertIsNone(setup_args.frontend_version) - self.assertIsNone(update_args.frontend_version) + 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") + ) + assert tag_name == "v9.9.9" + assert download_url == "https://example.com/dist.zip" + +def test_parser_leaves_frontend_version_empty_until_runtime_resolution(): + """未显式指定版本时延迟到运行阶段读取最新源码。""" + 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"]) + + assert install_args.version is None + assert setup_args.frontend_version is None + assert update_args.frontend_version is None + + +@pytest.mark.parametrize("enabled", ["true", "1", "yes", "false", "0"]) +@pytest.mark.parametrize("target", ["backend", "frontend", "all"]) +def test_update_uses_configured_dev_mode(monkeypatch, enabled, target): + """DEV 配置作用于所有手动更新目标,前端从最新 Release 解析。""" + module = load_local_setup_module() + monkeypatch.delenv("MOVIEPILOT_UPDATE_DEV", raising=False) + monkeypatch.setattr(module, "read_env_value", lambda _key: enabled) + monkeypatch.setattr(module, "_git_output", lambda *_args: "v3") + args = module.build_parser().parse_args(["update", target]) + assert module._resolve_update_versions(args) == ( + "latest", "latest" if enabled in {"true", "1", "yes"} else None + ) + + +@pytest.mark.parametrize("options, expected", [ + (["--dev"], ("v3", "latest")), + (["--no-dev"], ("latest", None)), + (["--dev", "--ref", "v3.0.0", "--frontend-version", "v3.0.0"], ("v3.0.0", "v3.0.0")), + (["--offline-backend", "--ref", "v3.0.0", "--frontend-version", "v3.0.0"], ("v3.0.0", "v3.0.0")), +]) +def test_explicit_update_options_and_detached_head(monkeypatch, options, expected): + """显式选项覆盖 DEV 默认值,离线安装保持下载清单的版本。""" + module = load_local_setup_module() + monkeypatch.setenv("MOVIEPILOT_UPDATE_DEV", "true") + monkeypatch.setattr(module, "_git_output", lambda *_args: "HEAD") + args = module.build_parser().parse_args(["update", *options]) + assert args.target == "all" + assert module._resolve_update_versions(args) == expected + + +def test_environment_overrides_saved_dev_config(monkeypatch): + """进程环境配置优先于 app.env 中保存的 DEV 偏好。""" + module = load_local_setup_module() + monkeypatch.setenv("MOVIEPILOT_UPDATE_DEV", "false") + monkeypatch.setattr(module, "read_env_value", lambda _key: "true") + assert module._resolve_update_versions(module.build_parser().parse_args(["update"])) == ("latest", None) + + +def test_dev_frontend_downloads_latest_release(): + """latest 必须访问最新 Release API,不读取后端固定前端版本。""" + module = load_local_setup_module() + release = {"tag_name": "v3.0.11", "assets": [{"name": "dist.zip", "browser_download_url": "https://example.com/dist.zip"}]} + with patch.object(module, "fetch_json", return_value=release) as fetch, patch.object( + module, "_repo_frontend_version", side_effect=AssertionError("不应读取固定版本") + ): + assert module._resolve_frontend_release("latest")[0] == "v3.0.11" + fetch.assert_called_once_with(module.FRONTEND_LATEST_API) + + +@pytest.mark.parametrize("target", ["backend", "frontend", "all"]) +def test_update_command_passes_dev_selection_to_installers(monkeypatch, tmp_path, target): + """命令入口把 DEV 选择传给实际安装边界,并保持后端先于前端执行。""" + module = load_local_setup_module() + monkeypatch.setenv("MOVIEPILOT_UPDATE_DEV", "true") + monkeypatch.setattr(module.sys, "argv", ["local_setup.py", "update", target, "--skip-resources"]) + monkeypatch.setattr(module, "configure_config_dir", lambda **_: tmp_path) + monkeypatch.setattr(module, "_git_output", lambda *_: "v3") + calls = [] + with patch.object(module, "ensure_services_stopped"), patch.object( + module, "update_backend", side_effect=lambda **kwargs: calls.append(("backend", kwargs["ref"])) + ), patch.object(module, "install_frontend", side_effect=lambda **kwargs: calls.append( + ("frontend", kwargs["frontend_version"]) + ) or {"version": "v3.0.11"}): + assert module.main() == 0 + assert calls == [(name, "latest") for name in ("backend", "frontend") if target in {name, "all"}] + + +@pytest.mark.parametrize("fetch, branch", [(True, "v3"), (False, "v3"), (True, "HEAD")]) +def test_switching_to_dev_branch_fetches_latest_tip(monkeypatch, tmp_path, fetch, branch): + """切回开发分支后快进远端提交;离线安装和标签检出不拉取分支。""" + module = load_local_setup_module() + (tmp_path / ".git").touch() + monkeypatch.setattr(module, "ROOT", tmp_path) + monkeypatch.setattr(module, "_ensure_git_clean", lambda: None) + monkeypatch.setattr(module, "_git_output", lambda *_: branch) + with patch.object(module, "run") as run: + assert module._update_backend_ref("v3", fetch=fetch) == "v3" + pull_calls = [call.args[0] for call in run.call_args_list if call.args[0][:2] == ["git", "pull"]] + assert pull_calls == ([["git", "pull", "--ff-only", "origin", "v3"]] if fetch and branch == "v3" else [])