From 44c41ec476be16c7f189cab0ca0844e0ffd62121 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 7 Sep 2026 13:35:52 +0800 Subject: [PATCH] fix(config): separate auto-update and dev tracking settings for clarity --- app/adapters/system/update.py | 14 ++++++- app/application/configuration.py | 6 +++ app/cli.py | 6 ++- app/runtime/config.py | 36 ++++++++--------- app/scheduler/catalog.py | 8 ++-- app/scheduler/facade.py | 1 + app/schemas/system.py | 3 ++ app/startup/composition/configuration.py | 3 +- docker/entrypoint.sh | 12 +++--- docker/update.sh | 7 +++- docs/cli.md | 2 +- docs/docker-startup.md | 7 ++-- docs/mcp-api.md | 4 +- docs/rules/03-commands.md | 2 +- tests/test_cli_auto_update.py | 19 ++++++++- tests/test_database_backup_scheduler.py | 28 +++++++------ tests/test_docker_bootstrap.py | 18 +++++++-- tests/test_system_update_manager.py | 48 +++++++++++++++++++++++ tests/test_system_utils.py | 50 +++++++++++++++++++++--- 19 files changed, 210 insertions(+), 64 deletions(-) diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index eccc13088..51a639949 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -292,7 +292,7 @@ class SystemUpdateManager(metaclass=SingletonClass): return min(100, int(downloaded_value * 100 / total_value)) def get_status(self) -> SystemUpdateStatus: - """返回状态快照,并在新进程中收敛已完成的安装状态。""" + """收敛安装状态并返回快照;提醒开关实时读取,避免缓存绕过关闭设置。""" with self._lock: state = self._read_state() changed = False @@ -334,6 +334,8 @@ class SystemUpdateManager(metaclass=SingletonClass): state = self._persist_state(self._sync_aggregate(state)) else: state = self._sync_aggregate(state) + state["auto_update"] = get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") is True + state["auto_update_resource"] = get_runtime_setting("AUTO_UPDATE_RESOURCE") is True return cast(SystemUpdateStatus, SystemUpdateStatus.model_validate(state)) def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool: @@ -374,6 +376,16 @@ class SystemUpdateManager(metaclass=SingletonClass): } ) + def check_scheduled(self) -> SystemUpdateStatus: + """按实时开关分别检查主程序和资源,避免热重载前的排队任务越过关闭设置。""" + for target, setting in ( + (_APPLICATION, "MOVIEPILOT_AUTO_UPDATE"), + (_RESOURCES, "AUTO_UPDATE_RESOURCE"), + ): + if get_runtime_setting(setting) is True: + self.check(target) + return self.get_status() + def check(self, target: SystemUpdateType | None = None) -> SystemUpdateStatus: """检查主程序和站点资源更新,定时检查失败只记录在对应明细中。""" targets = (target,) if target else _TARGETS diff --git a/app/application/configuration.py b/app/application/configuration.py index 889ae885f..dcb7b3ffd 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -179,6 +179,12 @@ class SchedulerRuntimeConfig: usage_statistic_share: bool site_link: str | None auto_update: bool = False + auto_update_resource: bool = True + + @property + def update_check_enabled(self) -> bool: + """主程序或资源任一检查开启时保留共享的定时检测服务。""" + return self.auto_update or self.auto_update_resource @dataclass(frozen=True, slots=True) diff --git a/app/cli.py b/app/cli.py index 32dc53d74..3d54340eb 100644 --- a/app/cli.py +++ b/app/cli.py @@ -243,9 +243,10 @@ def _git_current_branch() -> Optional[str]: def _auto_update_mode() -> str: + """启动时仅由独立 Dev 开关或一次性更新请求选择开发分支。""" if SystemHelper.consume_one_shot_dev_update(): return "dev" - return str(get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") or "").strip().lower() + return "dev" if get_runtime_setting("MOVIEPILOT_UPDATE_DEV") is True else "false" def _file_sha256(path: Path) -> str: @@ -490,6 +491,7 @@ def _resolve_auto_update_targets(mode: str) -> Optional[str]: def _best_effort_auto_update() -> None: + """优先应用已确认的安装包,再按 Dev 跟踪偏好更新;失败不阻断启动。""" if _apply_prepared_release_update(): return @@ -521,7 +523,7 @@ def _best_effort_auto_update() -> None: str(get_runtime_setting("CONFIG_PATH")), ] - click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新") + click.echo("检测到 Dev 跟踪开关或一次性更新请求,启动前执行本地开发版更新") result = subprocess.run( update_command, cwd=str(_repo_root()), diff --git a/app/runtime/config.py b/app/runtime/config.py index 3efc7d19b..a0dbfdacd 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -335,8 +335,10 @@ class ConfigModel(BaseModel): ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403" # ==================== 系统升级配置 ==================== - # 开发版仍可在启动时跟踪 v3 分支;Release 更新由后台更新服务管理。 - MOVIEPILOT_AUTO_UPDATE: str = "false" + # 自动检查稳定版本并提示升级,不自动下载或安装。 + MOVIEPILOT_AUTO_UPDATE: bool = False + # 独立控制启动时跟踪 v3 开发分支。 + MOVIEPILOT_UPDATE_DEV: bool = False # 后台检查站点资源包,确认后由启动器在进程拉起前应用 AUTO_UPDATE_RESOURCE: bool = True @@ -820,6 +822,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): ) -> Tuple[Any, bool]: """ 通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值 + 旧自动更新模式 dev/release 统一兼容为开启检查,运行时只保留布尔值。 :return: 元组 (转换后的值, 是否需要更新) """ if isinstance(value, (list, dict, set)): @@ -830,6 +833,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): if isinstance(value, str): value = value.strip() + if field_name == "MOVIEPILOT_AUTO_UPDATE" and value.lower() in {"dev", "release"}: + value = True # 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None # 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604) @@ -911,25 +916,18 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): @classmethod def generic_type_validator(cls, data: Any): # noqa """ - 通用校验器,尝试将配置值转换为期望的类型 + 通用校验器,迁移旧 Dev 跟踪偏好后将配置值转换为期望的类型。 """ if not isinstance(data, dict): return data - # 仅 true 表示启用后台 Release 检查,其他模式不注册该定时服务。 - if "MOVIEPILOT_AUTO_UPDATE" in data: - original_update_mode = data["MOVIEPILOT_AUTO_UPDATE"] - mode = str(original_update_mode or "").strip().lower() - normalized_update_mode = ( - mode if mode in {"true", "dev", "false"} else "false" - ) - if normalized_update_mode != str(original_update_mode): - cls.update_env_config( - "MOVIEPILOT_AUTO_UPDATE", - original_update_mode, - normalized_update_mode, - ) - data["MOVIEPILOT_AUTO_UPDATE"] = normalized_update_mode + # 新开关未配置时保留旧 Dev 跟踪偏好,显式设置的新开关始终优先。 + if ( + str(data.get("MOVIEPILOT_AUTO_UPDATE", "")).strip().lower() == "dev" + and "MOVIEPILOT_UPDATE_DEV" not in data + ): + cls.update_env_config("MOVIEPILOT_UPDATE_DEV", None, True) + data["MOVIEPILOT_UPDATE_DEV"] = True # 处理 API_TOKEN 特殊验证 if "API_TOKEN" in data: @@ -964,7 +962,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): field_name: str, original_value: Any, converted_value: Any ) -> Tuple[bool, str]: """ - 更新 env 配置 + 更新 env 配置;版本更新开关以小写 true/false 持久化,供启动脚本读取。 """ # 成功且无提示时使用空字符串,保证与 Tuple[bool, str] 返回类型一致 message = "" @@ -993,6 +991,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): # 如果是列表、字典或集合类型,将其转换为JSON字符串 if isinstance(converted_value, (list, dict, set)): value_to_write = json.dumps(converted_value) + elif field_name in {"MOVIEPILOT_AUTO_UPDATE", "MOVIEPILOT_UPDATE_DEV"}: + value_to_write = str(converted_value).lower() else: value_to_write = str(converted_value) diff --git a/app/scheduler/catalog.py b/app/scheduler/catalog.py index 7c844ff10..320a2c0bb 100644 --- a/app/scheduler/catalog.py +++ b/app/scheduler/catalog.py @@ -204,8 +204,8 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase): JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"), JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"), *( - [JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system")] - if config.auto_update + [JobSpec("system_update_check", "检查系统更新", system_update_manager.check_scheduled, "system")] + if config.update_check_enabled else [] ), ] @@ -428,8 +428,8 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase): kwargs={"job_id": "plugin_market_refresh"}, ) - if config.auto_update: - # 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。 + if config.update_check_enabled: + # 任一更新开关开启即注册,执行时分别检查已启用的主程序或资源。 self._scheduler.add_job( self.start, "interval", diff --git a/app/scheduler/facade.py b/app/scheduler/facade.py index 2a7d871bb..d612b8975 100644 --- a/app/scheduler/facade.py +++ b/app/scheduler/facade.py @@ -73,6 +73,7 @@ class Scheduler( "DB_BACKUP_CRON", "USAGE_STATISTIC_SHARE", "MOVIEPILOT_AUTO_UPDATE", + "AUTO_UPDATE_RESOURCE", } def __init__(self) -> None: diff --git a/app/schemas/system.py b/app/schemas/system.py index b052fd3f5..d47f2c673 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -253,6 +253,9 @@ class SystemUpdateRequest(BaseModel): # type: ignore[misc] class SystemUpdateStatus(BaseModel): """主程序与站点资源后台更新的聚合状态快照。""" + auto_update: bool = Field(default=False, description="是否启用主程序自动检查及升级提醒") + auto_update_resource: bool = Field(default=True, description="是否启用站点资源自动检查及升级提醒") + state: Literal[ "idle", "available", diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 805b21472..af4998d7d 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -189,7 +189,8 @@ def build_scheduler_runtime_config(settings: Settings) -> SchedulerRuntimeConfig ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL, usage_statistic_share=settings.USAGE_STATISTIC_SHARE, site_link=settings.MP_DOMAIN("#/site"), - auto_update=str(settings.MOVIEPILOT_AUTO_UPDATE).strip().lower() == "true", + auto_update=settings.MOVIEPILOT_AUTO_UPDATE, + auto_update_resource=settings.AUTO_UPDATE_RESOURCE, ) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index cba01ef3e..8d8e9b86c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -53,6 +53,7 @@ function apply_package_cache_env() { # 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值 # 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义) function load_config_from_app_env() { + # 保留未配置的新 Dev 开关为空,交由更新器兼容旧模式、Python 持久化迁移。 local env_file="${CONFIG_DIR}/app.env" @@ -66,6 +67,7 @@ function load_config_from_app_env() { ["PROXY_HOST"]="" ["GITHUB_TOKEN"]="" ["MOVIEPILOT_AUTO_UPDATE"]="false" + ["MOVIEPILOT_UPDATE_DEV"]="" ["MOVIEPILOT_FORCE_CHOWN"]="false" ["MOVIEPILOT_SAFE_MODE"]="false" ["BROWSER_EMULATION"]="cloakbrowser" @@ -323,10 +325,10 @@ function run_pending_dev_update_after_supervisor_shutdown() { fi local update_exit_code=0 - MOVIEPILOT_AUTO_UPDATE="dev" + MOVIEPILOT_UPDATE_DEV="true" INFO "检测到受管重启的 Dev 更新请求" run_moviepilot_update || update_exit_code=$? - MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" + MOVIEPILOT_UPDATE_DEV="${MOVIEPILOT_UPDATE_DEV_ORIGINAL}" [ "${update_exit_code}" -eq 0 ] \ && [ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ] @@ -460,10 +462,10 @@ apply_package_cache_env ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update" SUPERVISOR_RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart" ONE_SHOT_DEV_UPDATE="false" -MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}" +MOVIEPILOT_UPDATE_DEV_ORIGINAL="${MOVIEPILOT_UPDATE_DEV}" if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}" - MOVIEPILOT_AUTO_UPDATE="dev" + MOVIEPILOT_UPDATE_DEV="true" ONE_SHOT_DEV_UPDATE="true" INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支" fi @@ -489,7 +491,7 @@ else MOVIEPILOT_UPDATE_RESULT="noop" fi if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then - MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" + MOVIEPILOT_UPDATE_DEV="${MOVIEPILOT_UPDATE_DEV_ORIGINAL}" fi if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then ERROR "→ 容器更新回滚未完成,停止启动。" diff --git a/docker/update.sh b/docker/update.sh index f066d27c0..0b710d602 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -595,8 +595,13 @@ function configure_package_route() { } function run_moviepilot_update() { + # 新 Dev 开关独立于自动检查;仅在未配置新开关时兼容首次启动的旧 dev 值。 MOVIEPILOT_UPDATE_RESULT="noop" - if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then + local dev_update="${MOVIEPILOT_UPDATE_DEV:-}" + if [ -z "${dev_update}" ] && [[ "${MOVIEPILOT_AUTO_UPDATE:-}" == [Dd][Ee][Vv] ]]; then + dev_update="true" + fi + if [[ "${dev_update}" == [Tt][Rr][Uu][Ee] ]]; then TMP_PATH=$(mktemp -d) if [ ! -d "${TMP_PATH}" ]; then TMP_PATH=/tmp/mp_update_path diff --git a/docs/cli.md b/docs/cli.md index 26adfd458..c1b51f0f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -393,7 +393,7 @@ moviepilot version - `start` 会先启动后端,再启动前端 - `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置 -- `MOVIEPILOT_AUTO_UPDATE` 默认关闭;设置为 `true` 时启用后台 Release 检查,设置为 `dev` 时保留启动前跟踪当前 v3 开发分支的行为,更新失败只告警,不阻断当前启动 +- `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 校验的包 - 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示 - 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启 diff --git a/docs/docker-startup.md b/docs/docker-startup.md index d34132098..23c776a56 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -159,7 +159,7 @@ source 其他脚本,可能出现同一次启动混用新旧脚本的情况。 /config/temp/moviepilot.pending_dev_update ``` -entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_AUTO_UPDATE` 临时设为 `dev`。更新阶段结束后 +entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_UPDATE_DEV` 临时设为 `true`。更新阶段结束后 恢复原值,避免把一次性操作变成永久自动更新。 ### 5.2 未完成更新恢复 @@ -213,7 +213,7 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“ ### 5.4 Dev 自动更新 -仅当 `MOVIEPILOT_AUTO_UPDATE=dev` 时,启动脚本会联网获取 `v3` 分支源码和最新 V3 前端 Release。 +仅当 `MOVIEPILOT_UPDATE_DEV=true` 时(首次升级兼容尚未迁移且未配置新开关的旧 `MOVIEPILOT_AUTO_UPDATE=dev`),启动脚本会联网获取 `v3` 分支源码和最新 V3 前端 Release。 GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引按 `PIP_PROXY`、`PROXY_HOST`、 直连顺序选择。 @@ -458,7 +458,8 @@ Docker restart policy。 | `UMASK` | `000` | 后端进程文件权限掩码。 | | `PORT` | `3001` | 后端监听和 readiness 端口。 | | `NGINX_PORT` | `3000` | HTTP 前端入口。 | -| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发 `update.sh` 的启动时分支更新;稳定版由后台下载和 root worker 安装。 | +| `MOVIEPILOT_AUTO_UPDATE` | `false` | 布尔开关,仅 `true` 开启后台版本检查和升级提醒;关闭后不检查主程序;`AUTO_UPDATE_RESOURCE=true` 时仍启用服务且只检查站点资源。稳定版下载及安装需手动确认。旧 `dev/release` 统一迁移为 `true`。 | +| `MOVIEPILOT_UPDATE_DEV` | `false` | 独立布尔开关,`true` 触发 `update.sh` 的启动时 Dev 分支更新;旧 `dev` 在未显式配置此开关时保留跟踪偏好。 | | `MOVIEPILOT_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 | | `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 | | `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 43cf97ea3..f3dab70d4 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -253,11 +253,11 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 #### 系统更新 -系统 Release 更新采用“检查、后台下载、确认安装”三阶段流程,以下接口均要求超级管理员登录态。后台每 6 小时自动检查一次稳定版 v3 GitHub Release 和站点资源包;升级类型只有 `application`(主程序,前端版本由后端 Release 中的 `version.py` 决定)与 `resources`(认证资源和索引资源)。下载完成前不重启服务,安装接口只消费已下载并校验的完整制品,启动器会先应用主程序包,再应用资源包,之后才启动进程;启动后的初始化不会再次下载或触发资源重启。原 Dev 更新入口继续保留,但 `/system/upgrade` 只接受请求体 `"dev"`,不再处理 Release 更新。 +系统 Release 更新采用“检查、后台下载、确认安装”三阶段流程,以下接口均要求超级管理员登录态。后台每 6 小时按独立开关检查更新:`MOVIEPILOT_AUTO_UPDATE=true` 检查稳定版 v3 GitHub Release,`AUTO_UPDATE_RESOURCE=true` 检查站点资源包,并分别提示升级。任一开关开启即启用定时服务;两者均关闭时移除定时服务并隐藏版本提醒。手动检查、下载和安装仍可用。独立布尔配置 `MOVIEPILOT_UPDATE_DEV` 控制启动时跟踪 Dev 分支;升级类型只有 `application`(主程序,前端版本由后端 Release 中的 `version.py` 决定)与 `resources`(认证资源和索引资源)。下载完成前不重启服务,安装接口只消费已下载并校验的完整制品,启动器会先应用主程序包,再应用资源包,之后才启动进程;启动后的初始化不会再次下载或触发资源重启。原 Dev 更新入口继续保留,但 `/system/upgrade` 只接受请求体 `"dev"`,不再处理 Release 更新。 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/system/update/status` | 查询聚合状态及 `updates` 中两类升级明细的 `idle`、`available`、`downloading`、`ready`、`installing` 或 `failed` 状态,以及版本、字节数和进度 | +| GET | `/api/v1/system/update/status` | 查询聚合状态、实时提醒开关 `auto_update` / `auto_update_resource` 及 `updates` 中两类升级明细的 `idle`、`available`、`downloading`、`ready`、`installing` 或 `failed` 状态,以及版本、字节数和进度 | | POST | `/api/v1/system/update/check` | 立即检查最新稳定版 v3 Release 和当前平台站点资源包 | | POST | `/api/v1/system/update/download` | 请求体可传 `{"target":"application"}` 或 `{"target":"resources"}`;后台下载并校验对应制品 | | POST | `/api/v1/system/update/install` | 请求体可传 `{"target":"application"}` 或 `{"target":"resources"}`;再次校验对应制品,写入安装意图并重启 | diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index c4b29e0a4..cac383cab 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -177,7 +177,7 @@ moviepilot update all --ref latest --frontend-version latest moviepilot update all --skip-resources ``` -`MOVIEPILOT_AUTO_UPDATE` defaults to `false`. Setting it to `true` enables the background Release check; setting it to `dev` retains branch-tracking updates during `start/restart`. The setting is hot-reloaded by the scheduler. +`MOVIEPILOT_AUTO_UPDATE` is a boolean (default `false`): `true` enables the background Release check and version reminders, and `false` disables application checks and reminders. `AUTO_UPDATE_RESOURCE` independently enables resource checks and reminders; the scheduled service exists when either switch is enabled and checks only enabled targets. The scheduler hot-reloads this switch. `MOVIEPILOT_UPDATE_DEV` is an independent boolean (default `false`) that enables development-branch updates during `start/restart`. Legacy `dev`/`release` values of `MOVIEPILOT_AUTO_UPDATE` normalize to `true`; legacy `dev` also preserves Dev tracking when the new switch is not explicitly configured. --- diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py index fe420ccfa..217bdbfe1 100644 --- a/tests/test_cli_auto_update.py +++ b/tests/test_cli_auto_update.py @@ -10,6 +10,8 @@ from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch +import pytest + MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py" @@ -20,6 +22,7 @@ class _DummySystemHelper: def load_cli_module(): + """隔离加载 CLI,使用真实布尔配置形状验证启动更新决策。""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) settings = SimpleNamespace( @@ -35,7 +38,8 @@ def load_cli_module(): PROXY_HOST="", PIP_PROXY="", GITHUB_TOKEN="", - MOVIEPILOT_AUTO_UPDATE="false", + MOVIEPILOT_AUTO_UPDATE=False, + MOVIEPILOT_UPDATE_DEV=False, PROXY={}, REPO_GITHUB_HEADERS=lambda _repo: {}, ) @@ -95,8 +99,9 @@ def test_resolve_auto_update_targets_keeps_dev_branch_tracking(): def test_one_shot_dev_update_overrides_disabled_default(): + """一次性手动更新不受两个自动开关关闭的影响。""" module = load_cli_module() - module.settings.MOVIEPILOT_AUTO_UPDATE = "false" + module.settings.MOVIEPILOT_AUTO_UPDATE = False with patch.object( module.SystemHelper, "consume_one_shot_dev_update", return_value=True @@ -104,6 +109,16 @@ def test_one_shot_dev_update_overrides_disabled_default(): assert module._auto_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") + + def test_release_mode_does_not_update_during_start(): module = load_cli_module() with patch.object(module, "_auto_update_mode", return_value="release"), patch.object( diff --git a/tests/test_database_backup_scheduler.py b/tests/test_database_backup_scheduler.py index f86727327..3e5c32b42 100644 --- a/tests/test_database_backup_scheduler.py +++ b/tests/test_database_backup_scheduler.py @@ -6,6 +6,8 @@ from dataclasses import replace from pathlib import Path from unittest.mock import Mock +import pytest + from app.application.configuration import SchedulerRuntimeConfig from app.scheduler import catalog as scheduler_catalog from app.scheduler import maintenance as scheduler_maintenance @@ -58,6 +60,7 @@ def _config(**changes) -> SchedulerRuntimeConfig: usage_statistic_share=False, site_link=None, auto_update=False, + auto_update_resource=False, ) return replace(config, **changes) @@ -74,8 +77,9 @@ def test_database_backup_schedule_only_watches_job_shape() -> None: def test_auto_update_setting_is_hot_reloadable() -> None: - """自动更新开关变更时应触发 Scheduler 重建。""" + """主程序或资源开关变更时均应触发 Scheduler 重建。""" assert "MOVIEPILOT_AUTO_UPDATE" in Scheduler.CONFIG_WATCH + assert "AUTO_UPDATE_RESOURCE" in Scheduler.CONFIG_WATCH def test_disabled_database_backup_does_not_register_job() -> None: @@ -108,8 +112,12 @@ def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) - assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True -def test_auto_update_check_is_registered_only_when_enabled(monkeypatch) -> None: - """只有显式开启自动更新时才注册 Release 检查任务。""" +@pytest.mark.parametrize("auto_update", [False, True]) +@pytest.mark.parametrize("auto_update_resource", [False, True]) +def test_auto_update_check_is_registered_only_when_enabled( + monkeypatch, auto_update, auto_update_resource +) -> None: + """任一开关开启即注册检查任务,均关闭则不注册。""" scheduler = _scheduler() scheduler._services = Mock() background_scheduler = Mock() @@ -120,20 +128,14 @@ def test_auto_update_check_is_registered_only_when_enabled(monkeypatch) -> None: monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) - scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(scheduler, _config(auto_update=False)) - assert not any( - call.kwargs.get("id") == "system_update_check" - for call in background_scheduler.add_job.call_args_list + scheduler_catalog.SchedulerCatalogOwner._initialize_catalog( + scheduler, _config(auto_update=auto_update, auto_update_resource=auto_update_resource) ) - assert "system_update_check" not in scheduler._jobs - - background_scheduler.add_job.reset_mock() - scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(scheduler, _config(auto_update=True)) assert any( call.kwargs.get("id") == "system_update_check" for call in background_scheduler.add_job.call_args_list - ) - assert "system_update_check" in scheduler._jobs + ) is (auto_update or auto_update_resource) + assert ("system_update_check" in scheduler._jobs) is (auto_update or auto_update_resource) def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None: diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 4137608d4..954a8d819 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -794,17 +794,27 @@ def test_updater_package_proxy_stays_command_scoped(tmp_path: Path) -> None: @pytest.mark.parametrize( - ("mode", "install_result", "expected"), - (("false", "unused", "noop"), ("dev", "success", "updated"), ("dev", "failure", "failed")), + ("mode", "dev_update", "install_result", "expected"), + ( + ("false", "false", "unused", "noop"), + ("true", "false", "unused", "noop"), + ("false", "true", "success", "updated"), + ("true", "True", "failure", "failed"), + ("dev", "", "success", "updated"), + ("dev", "false", "unused", "noop"), + ("release", "", "unused", "noop"), + ), ) def test_updater_exposes_explicit_result( - tmp_path: Path, mode: str, install_result: str, expected: str + tmp_path: Path, mode: str, dev_update: str, install_result: str, expected: str ) -> None: + """Docker 由独立 Dev 开关决定启动更新,并兼容首次迁移的旧模式。""" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" MOVIEPILOT_AUTO_UPDATE="$2" INSTALL_RESULT="$3" + MOVIEPILOT_UPDATE_DEV="$4" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN= source {UPDATER!s} INFO() {{ :; }} @@ -825,7 +835,7 @@ def test_updater_exposes_explicit_result( ) result = subprocess.run( - ["bash", "-c", script, "updater-test", str(tmp_path / "config"), mode, install_result], + ["bash", "-c", script, "updater-test", str(tmp_path / "config"), mode, install_result, dev_update], text=True, capture_output=True, check=True, diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index e794882c5..26bc48c6a 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -32,6 +32,8 @@ def _docker_manager(monkeypatch, tmp_path: Path): "FRONTEND_PATH": tmp_path / "public", "VENV_PATH": tmp_path / "venv", "UV_BIN": tmp_path / "uv", + "MOVIEPILOT_AUTO_UPDATE": False, + "AUTO_UPDATE_RESOURCE": True, "PIP_PROXY": "", "PROXY_HOST": "", } @@ -52,6 +54,52 @@ def _response(payload, status_code=200): return SimpleNamespace(status_code=status_code, json=lambda: payload) +def test_status_reads_live_auto_update_setting_without_discarding_cached_update(monkeypatch, tmp_path): + """切换提醒设置立即反映到状态,缓存版本仍供手动升级使用。""" + manager = _manager(monkeypatch, tmp_path) + manager._write_state(state="available", version="v3.1.0", can_update=True) + for enabled in (True, False, True): + monkeypatch.setattr( + update_module, "get_runtime_setting", + lambda key: tmp_path if key == "TEMP_PATH" else enabled, + ) + status = manager.get_status() + assert status.auto_update is enabled + assert status.state == "available" + assert status.version == "v3.1.0" + assert status.can_update is True + + +@pytest.mark.parametrize("auto_update", [False, True]) +@pytest.mark.parametrize("auto_update_resource", [False, True]) +def test_scheduled_check_respects_independent_switches( + monkeypatch, tmp_path, auto_update, auto_update_resource +): + """自动检查只访问已开启的目标;手动检查仍可访问两类更新。""" + manager = _manager(monkeypatch, tmp_path) + values = { + "TEMP_PATH": tmp_path, + "MOVIEPILOT_AUTO_UPDATE": auto_update, + "AUTO_UPDATE_RESOURCE": auto_update_resource, + } + monkeypatch.setattr(update_module, "get_runtime_setting", values.get) + checked = [] + monkeypatch.setattr(manager, "_check_application", lambda: checked.append("application")) + monkeypatch.setattr(manager, "_check_resources", lambda: checked.append("resources")) + + status = manager.check_scheduled() + assert checked == [ + target for target, enabled in (("application", auto_update), ("resources", auto_update_resource)) + if enabled + ] + assert status.auto_update is auto_update + assert status.auto_update_resource is auto_update_resource + + checked.clear() + manager.check() + assert checked == ["application", "resources"] + + def test_check_exposes_new_stable_release(monkeypatch, tmp_path): manager = _manager(monkeypatch, tmp_path) logs = [] diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 9808acace..825cc58e6 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -538,8 +538,9 @@ def test_btrfs_fsid_dedup_setting_is_opt_in(): assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True -def test_auto_update_mode_is_normalized(monkeypatch): - """自动更新仅保留 true、dev 和 false 三种运行模式。""" +@pytest.mark.parametrize("mode", ["release", "dev", " DEV ", "RELEASE"]) +def test_auto_update_mode_is_normalized(monkeypatch, mode): + """旧模式规范化为布尔 true,已有的新 Dev 偏好不被覆盖。""" updates = [] monkeypatch.setattr( Settings, @@ -549,14 +550,51 @@ def test_auto_update_mode_is_normalized(monkeypatch): ), ) - assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false" - assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "true" - assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev" + config = Settings(MOVIEPILOT_AUTO_UPDATE=mode, MOVIEPILOT_UPDATE_DEV=False) + assert config.MOVIEPILOT_AUTO_UPDATE is True + assert config.MOVIEPILOT_UPDATE_DEV is False assert updates == [ - ("MOVIEPILOT_AUTO_UPDATE", "release", "false"), + ("MOVIEPILOT_AUTO_UPDATE", mode, True), ] +def test_legacy_dev_tracking_is_migrated_once(monkeypatch, tmp_path): + """首次拆分配置时持久化两个开关,重读后继续保留 Dev 跟踪。""" + env_file = tmp_path / "app.env" + env_file.write_text("MOVIEPILOT_AUTO_UPDATE='dev'\n", encoding="utf-8") + monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_file) + config = Settings(_env_file=env_file) + assert config.MOVIEPILOT_AUTO_UPDATE is True + assert config.MOVIEPILOT_UPDATE_DEV is True + assert "MOVIEPILOT_AUTO_UPDATE='true'" in env_file.read_text(encoding="utf-8") + assert "MOVIEPILOT_UPDATE_DEV='true'" in env_file.read_text(encoding="utf-8") + reloaded = Settings(_env_file=env_file) + assert reloaded.MOVIEPILOT_AUTO_UPDATE is True + assert reloaded.MOVIEPILOT_UPDATE_DEV is True + + +@pytest.mark.parametrize("enabled", [True, False, "true", "false"]) +def test_update_switches_remain_independent_booleans(enabled): + """部署设置与调度快照仅暴露布尔值,Dev 跟踪不影响自动检查。""" + from app.startup.composition.configuration import build_scheduler_runtime_config + + expected = str(enabled).lower() == "true" + config = Settings(MOVIEPILOT_AUTO_UPDATE=enabled, MOVIEPILOT_UPDATE_DEV=not expected) + assert config.MOVIEPILOT_AUTO_UPDATE is expected + assert config.MOVIEPILOT_UPDATE_DEV is not expected + assert build_scheduler_runtime_config(config).auto_update is expected + + +@pytest.mark.parametrize("value", ["dev", "release", True, False]) +def test_update_setting_normalizes_auto_update_on_save(monkeypatch, value): + """设置写入入口与启动读取入口使用同一套布尔转换规则。""" + config = Settings(MOVIEPILOT_AUTO_UPDATE=False, MOVIEPILOT_UPDATE_DEV=False) + monkeypatch.setattr(Settings, "update_env_config", lambda *_args: (True, "")) + config.update_setting("MOVIEPILOT_AUTO_UPDATE", value) + assert config.MOVIEPILOT_AUTO_UPDATE is (value is not False) + assert config.MOVIEPILOT_UPDATE_DEV is False + + def test_space_usage_default_path_does_not_read_fsid(): with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2: paths = [Path(tmp1), Path(tmp2)]