mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(config): separate auto-update and dev tracking settings for clarity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -73,6 +73,7 @@ class Scheduler(
|
||||
"DB_BACKUP_CRON",
|
||||
"USAGE_STATISTIC_SHARE",
|
||||
"MOVIEPILOT_AUTO_UPDATE",
|
||||
"AUTO_UPDATE_RESOURCE",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 安装模式也会复用同一套前后端进程管理完成重启
|
||||
|
||||
@@ -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
@@ -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"}`;再次校验对应制品,写入安装意图并重启 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user