fix(config): separate auto-update and dev tracking settings for clarity

This commit is contained in:
jxxghp
2026-09-07 13:35:52 +08:00
parent 6ae5b9d9f0
commit 44c41ec476
19 changed files with 210 additions and 64 deletions
+13 -1
View File
@@ -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
+6
View File
@@ -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)
+4 -2
View File
@@ -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()),
+18 -18
View File
@@ -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)
+4 -4
View File
@@ -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",
+1
View File
@@ -73,6 +73,7 @@ class Scheduler(
"DB_BACKUP_CRON",
"USAGE_STATISTIC_SHARE",
"MOVIEPILOT_AUTO_UPDATE",
"AUTO_UPDATE_RESOURCE",
}
def __init__(self) -> None:
+3
View File
@@ -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",
+2 -1
View File
@@ -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,
)
+7 -5
View File
@@ -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 "→ 容器更新回滚未完成,停止启动。"
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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 安装模式也会复用同一套前后端进程管理完成重启
+4 -3
View File
@@ -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` | 包管理缓存根目录。 |
+2 -2
View File
@@ -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"}`;再次校验对应制品,写入安装意图并重启 |
+1 -1
View File
@@ -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.
---
+17 -2
View File
@@ -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(
+15 -13
View File
@@ -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:
+14 -4
View File
@@ -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,
+48
View File
@@ -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 = []
+44 -6
View File
@@ -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)]