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)) return min(100, int(downloaded_value * 100 / total_value))
def get_status(self) -> SystemUpdateStatus: def get_status(self) -> SystemUpdateStatus:
"""返回状态快照,并在新进程中收敛已完成的安装状态""" """收敛安装状态并返回快照;提醒开关实时读取,避免缓存绕过关闭设置"""
with self._lock: with self._lock:
state = self._read_state() state = self._read_state()
changed = False changed = False
@@ -334,6 +334,8 @@ class SystemUpdateManager(metaclass=SingletonClass):
state = self._persist_state(self._sync_aggregate(state)) state = self._persist_state(self._sync_aggregate(state))
else: else:
state = self._sync_aggregate(state) 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)) return cast(SystemUpdateStatus, SystemUpdateStatus.model_validate(state))
def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool: 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: def check(self, target: SystemUpdateType | None = None) -> SystemUpdateStatus:
"""检查主程序和站点资源更新,定时检查失败只记录在对应明细中。""" """检查主程序和站点资源更新,定时检查失败只记录在对应明细中。"""
targets = (target,) if target else _TARGETS targets = (target,) if target else _TARGETS
+6
View File
@@ -179,6 +179,12 @@ class SchedulerRuntimeConfig:
usage_statistic_share: bool usage_statistic_share: bool
site_link: str | None site_link: str | None
auto_update: bool = False 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) @dataclass(frozen=True, slots=True)
+4 -2
View File
@@ -243,9 +243,10 @@ def _git_current_branch() -> Optional[str]:
def _auto_update_mode() -> str: def _auto_update_mode() -> str:
"""启动时仅由独立 Dev 开关或一次性更新请求选择开发分支。"""
if SystemHelper.consume_one_shot_dev_update(): if SystemHelper.consume_one_shot_dev_update():
return "dev" 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: 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: def _best_effort_auto_update() -> None:
"""优先应用已确认的安装包,再按 Dev 跟踪偏好更新;失败不阻断启动。"""
if _apply_prepared_release_update(): if _apply_prepared_release_update():
return return
@@ -521,7 +523,7 @@ def _best_effort_auto_update() -> None:
str(get_runtime_setting("CONFIG_PATH")), str(get_runtime_setting("CONFIG_PATH")),
] ]
click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新") click.echo("检测到 Dev 跟踪开关或一次性更新请求,启动前执行本地开发版更新")
result = subprocess.run( result = subprocess.run(
update_command, update_command,
cwd=str(_repo_root()), cwd=str(_repo_root()),
+18 -18
View File
@@ -335,8 +335,10 @@ class ConfigModel(BaseModel):
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403" 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 AUTO_UPDATE_RESOURCE: bool = True
@@ -820,6 +822,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
) -> Tuple[Any, bool]: ) -> Tuple[Any, bool]:
""" """
通用类型转换函数根据预期类型转换值如果转换失败返回默认值 通用类型转换函数根据预期类型转换值如果转换失败返回默认值
旧自动更新模式 dev/release 统一兼容为开启检查运行时只保留布尔值
:return: 元组 (转换后的值, 是否需要更新) :return: 元组 (转换后的值, 是否需要更新)
""" """
if isinstance(value, (list, dict, set)): if isinstance(value, (list, dict, set)):
@@ -830,6 +833,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
if isinstance(value, str): if isinstance(value, str):
value = value.strip() value = value.strip()
if field_name == "MOVIEPILOT_AUTO_UPDATE" and value.lower() in {"dev", "release"}:
value = True
# 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None # 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None
# 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604) # 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604)
@@ -911,25 +916,18 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
@classmethod @classmethod
def generic_type_validator(cls, data: Any): # noqa def generic_type_validator(cls, data: Any): # noqa
""" """
通用校验器尝试将配置值转换为期望的类型 通用校验器迁移旧 Dev 跟踪偏好后将配置值转换为期望的类型
""" """
if not isinstance(data, dict): if not isinstance(data, dict):
return data return data
# 仅 true 表示启用后台 Release 检查,其他模式不注册该定时服务 # 新开关未配置时保留旧 Dev 跟踪偏好,显式设置的新开关始终优先
if "MOVIEPILOT_AUTO_UPDATE" in data: if (
original_update_mode = data["MOVIEPILOT_AUTO_UPDATE"] str(data.get("MOVIEPILOT_AUTO_UPDATE", "")).strip().lower() == "dev"
mode = str(original_update_mode or "").strip().lower() and "MOVIEPILOT_UPDATE_DEV" not in data
normalized_update_mode = ( ):
mode if mode in {"true", "dev", "false"} else "false" cls.update_env_config("MOVIEPILOT_UPDATE_DEV", None, True)
) data["MOVIEPILOT_UPDATE_DEV"] = True
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
# 处理 API_TOKEN 特殊验证 # 处理 API_TOKEN 特殊验证
if "API_TOKEN" in data: if "API_TOKEN" in data:
@@ -964,7 +962,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
field_name: str, original_value: Any, converted_value: Any field_name: str, original_value: Any, converted_value: Any
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
更新 env 配置 更新 env 配置版本更新开关以小写 true/false 持久化供启动脚本读取
""" """
# 成功且无提示时使用空字符串,保证与 Tuple[bool, str] 返回类型一致 # 成功且无提示时使用空字符串,保证与 Tuple[bool, str] 返回类型一致
message = "" message = ""
@@ -993,6 +991,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
# 如果是列表、字典或集合类型,将其转换为JSON字符串 # 如果是列表、字典或集合类型,将其转换为JSON字符串
if isinstance(converted_value, (list, dict, set)): if isinstance(converted_value, (list, dict, set)):
value_to_write = json.dumps(converted_value) 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: else:
value_to_write = str(converted_value) 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("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"), JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
*( *(
[JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system")] [JobSpec("system_update_check", "检查系统更新", system_update_manager.check_scheduled, "system")]
if config.auto_update if config.update_check_enabled
else [] else []
), ),
] ]
@@ -428,8 +428,8 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase):
kwargs={"job_id": "plugin_market_refresh"}, kwargs={"job_id": "plugin_market_refresh"},
) )
if config.auto_update: if config.update_check_enabled:
# 更新检查只缓存 Release 元数据,不会在未授权时下载或重启 # 任一更新开关开启即注册,执行时分别检查已启用的主程序或资源
self._scheduler.add_job( self._scheduler.add_job(
self.start, self.start,
"interval", "interval",
+1
View File
@@ -73,6 +73,7 @@ class Scheduler(
"DB_BACKUP_CRON", "DB_BACKUP_CRON",
"USAGE_STATISTIC_SHARE", "USAGE_STATISTIC_SHARE",
"MOVIEPILOT_AUTO_UPDATE", "MOVIEPILOT_AUTO_UPDATE",
"AUTO_UPDATE_RESOURCE",
} }
def __init__(self) -> None: def __init__(self) -> None:
+3
View File
@@ -253,6 +253,9 @@ class SystemUpdateRequest(BaseModel): # type: ignore[misc]
class SystemUpdateStatus(BaseModel): class SystemUpdateStatus(BaseModel):
"""主程序与站点资源后台更新的聚合状态快照。""" """主程序与站点资源后台更新的聚合状态快照。"""
auto_update: bool = Field(default=False, description="是否启用主程序自动检查及升级提醒")
auto_update_resource: bool = Field(default=True, description="是否启用站点资源自动检查及升级提醒")
state: Literal[ state: Literal[
"idle", "idle",
"available", "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, ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL,
usage_statistic_share=settings.USAGE_STATISTIC_SHARE, usage_statistic_share=settings.USAGE_STATISTIC_SHARE,
site_link=settings.MP_DOMAIN("#/site"), 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 文件 (即使为空字符串) -> 预设默认值 # 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义) # 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
function load_config_from_app_env() { function load_config_from_app_env() {
# 保留未配置的新 Dev 开关为空,交由更新器兼容旧模式、Python 持久化迁移。
local env_file="${CONFIG_DIR}/app.env" local env_file="${CONFIG_DIR}/app.env"
@@ -66,6 +67,7 @@ function load_config_from_app_env() {
["PROXY_HOST"]="" ["PROXY_HOST"]=""
["GITHUB_TOKEN"]="" ["GITHUB_TOKEN"]=""
["MOVIEPILOT_AUTO_UPDATE"]="false" ["MOVIEPILOT_AUTO_UPDATE"]="false"
["MOVIEPILOT_UPDATE_DEV"]=""
["MOVIEPILOT_FORCE_CHOWN"]="false" ["MOVIEPILOT_FORCE_CHOWN"]="false"
["MOVIEPILOT_SAFE_MODE"]="false" ["MOVIEPILOT_SAFE_MODE"]="false"
["BROWSER_EMULATION"]="cloakbrowser" ["BROWSER_EMULATION"]="cloakbrowser"
@@ -323,10 +325,10 @@ function run_pending_dev_update_after_supervisor_shutdown() {
fi fi
local update_exit_code=0 local update_exit_code=0
MOVIEPILOT_AUTO_UPDATE="dev" MOVIEPILOT_UPDATE_DEV="true"
INFO "检测到受管重启的 Dev 更新请求" INFO "检测到受管重启的 Dev 更新请求"
run_moviepilot_update || update_exit_code=$? 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 ] \ [ "${update_exit_code}" -eq 0 ] \
&& [ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ] && [ "${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" ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update"
SUPERVISOR_RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart" SUPERVISOR_RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart"
ONE_SHOT_DEV_UPDATE="false" 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 if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}" rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}"
MOVIEPILOT_AUTO_UPDATE="dev" MOVIEPILOT_UPDATE_DEV="true"
ONE_SHOT_DEV_UPDATE="true" ONE_SHOT_DEV_UPDATE="true"
INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支" INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支"
fi fi
@@ -489,7 +491,7 @@ else
MOVIEPILOT_UPDATE_RESULT="noop" MOVIEPILOT_UPDATE_RESULT="noop"
fi fi
if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" MOVIEPILOT_UPDATE_DEV="${MOVIEPILOT_UPDATE_DEV_ORIGINAL}"
fi fi
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
ERROR "→ 容器更新回滚未完成,停止启动。" ERROR "→ 容器更新回滚未完成,停止启动。"
+6 -1
View File
@@ -595,8 +595,13 @@ function configure_package_route() {
} }
function run_moviepilot_update() { function run_moviepilot_update() {
# 新 Dev 开关独立于自动检查;仅在未配置新开关时兼容首次启动的旧 dev 值。
MOVIEPILOT_UPDATE_RESULT="noop" 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) TMP_PATH=$(mktemp -d)
if [ ! -d "${TMP_PATH}" ]; then if [ ! -d "${TMP_PATH}" ]; then
TMP_PATH=/tmp/mp_update_path TMP_PATH=/tmp/mp_update_path
+1 -1
View File
@@ -393,7 +393,7 @@ moviepilot version
- `start` 会先启动后端,再启动前端 - `start` 会先启动后端,再启动前端
- `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置 - `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 校验的包 - Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,启动阶段只安装已下载且通过 SHA-256 校验的包
- 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示 - 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启 - 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
+4 -3
View File
@@ -159,7 +159,7 @@ source 其他脚本,可能出现同一次启动混用新旧脚本的情况。
/config/temp/moviepilot.pending_dev_update /config/temp/moviepilot.pending_dev_update
``` ```
entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_AUTO_UPDATE` 临时设为 `dev`。更新阶段结束后 entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_UPDATE_DEV` 临时设为 `true`。更新阶段结束后
恢复原值,避免把一次性操作变成永久自动更新。 恢复原值,避免把一次性操作变成永久自动更新。
### 5.2 未完成更新恢复 ### 5.2 未完成更新恢复
@@ -213,7 +213,7 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“
### 5.4 Dev 自动更新 ### 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` GitHub 访问按 `GITHUB_PROXY``PROXY_HOST`、直连顺序选择;包索引按 `PIP_PROXY``PROXY_HOST`
直连顺序选择。 直连顺序选择。
@@ -458,7 +458,8 @@ Docker restart policy。
| `UMASK` | `000` | 后端进程文件权限掩码。 | | `UMASK` | `000` | 后端进程文件权限掩码。 |
| `PORT` | `3001` | 后端监听和 readiness 端口。 | | `PORT` | `3001` | 后端监听和 readiness 端口。 |
| `NGINX_PORT` | `3000` | HTTP 前端入口。 | | `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_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 |
| `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 | | `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 |
| `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 | | `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/check` | 立即检查最新稳定版 v3 Release 和当前平台站点资源包 |
| POST | `/api/v1/system/update/download` | 请求体可传 `{"target":"application"}``{"target":"resources"}`;后台下载并校验对应制品 | | POST | `/api/v1/system/update/download` | 请求体可传 `{"target":"application"}``{"target":"resources"}`;后台下载并校验对应制品 |
| POST | `/api/v1/system/update/install` | 请求体可传 `{"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 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 types import ModuleType, SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import pytest
MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py" MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
@@ -20,6 +22,7 @@ class _DummySystemHelper:
def load_cli_module(): def load_cli_module():
"""隔离加载 CLI,使用真实布尔配置形状验证启动更新决策。"""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) root = Path(temp_dir)
settings = SimpleNamespace( settings = SimpleNamespace(
@@ -35,7 +38,8 @@ def load_cli_module():
PROXY_HOST="", PROXY_HOST="",
PIP_PROXY="", PIP_PROXY="",
GITHUB_TOKEN="", GITHUB_TOKEN="",
MOVIEPILOT_AUTO_UPDATE="false", MOVIEPILOT_AUTO_UPDATE=False,
MOVIEPILOT_UPDATE_DEV=False,
PROXY={}, PROXY={},
REPO_GITHUB_HEADERS=lambda _repo: {}, 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(): def test_one_shot_dev_update_overrides_disabled_default():
"""一次性手动更新不受两个自动开关关闭的影响。"""
module = load_cli_module() module = load_cli_module()
module.settings.MOVIEPILOT_AUTO_UPDATE = "false" module.settings.MOVIEPILOT_AUTO_UPDATE = False
with patch.object( with patch.object(
module.SystemHelper, "consume_one_shot_dev_update", return_value=True 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" 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(): def test_release_mode_does_not_update_during_start():
module = load_cli_module() module = load_cli_module()
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object( 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 pathlib import Path
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from app.application.configuration import SchedulerRuntimeConfig from app.application.configuration import SchedulerRuntimeConfig
from app.scheduler import catalog as scheduler_catalog from app.scheduler import catalog as scheduler_catalog
from app.scheduler import maintenance as scheduler_maintenance from app.scheduler import maintenance as scheduler_maintenance
@@ -58,6 +60,7 @@ def _config(**changes) -> SchedulerRuntimeConfig:
usage_statistic_share=False, usage_statistic_share=False,
site_link=None, site_link=None,
auto_update=False, auto_update=False,
auto_update_resource=False,
) )
return replace(config, **changes) 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: def test_auto_update_setting_is_hot_reloadable() -> None:
"""自动更新开关变更时应触发 Scheduler 重建。""" """主程序或资源开关变更时应触发 Scheduler 重建。"""
assert "MOVIEPILOT_AUTO_UPDATE" in Scheduler.CONFIG_WATCH 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: 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 assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True
def test_auto_update_check_is_registered_only_when_enabled(monkeypatch) -> None: @pytest.mark.parametrize("auto_update", [False, True])
"""只有显式开启自动更新时才注册 Release 检查任务。""" @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 = _scheduler()
scheduler._services = Mock() scheduler._services = Mock()
background_scheduler = 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_agent_task_jobs", lambda: None)
monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None) monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None)
scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(scheduler, _config(auto_update=False)) scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(
assert not any( scheduler, _config(auto_update=auto_update, auto_update_resource=auto_update_resource)
call.kwargs.get("id") == "system_update_check"
for call in background_scheduler.add_job.call_args_list
) )
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( assert any(
call.kwargs.get("id") == "system_update_check" call.kwargs.get("id") == "system_update_check"
for call in background_scheduler.add_job.call_args_list for call in background_scheduler.add_job.call_args_list
) ) is (auto_update or auto_update_resource)
assert "system_update_check" in scheduler._jobs assert ("system_update_check" in scheduler._jobs) is (auto_update or auto_update_resource)
def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None: 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( @pytest.mark.parametrize(
("mode", "install_result", "expected"), ("mode", "dev_update", "install_result", "expected"),
(("false", "unused", "noop"), ("dev", "success", "updated"), ("dev", "failure", "failed")), (
("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( 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: ) -> None:
"""Docker 由独立 Dev 开关决定启动更新,并兼容首次迁移的旧模式。"""
script = textwrap.dedent( script = textwrap.dedent(
f"""\ f"""\
CONFIG_DIR="$1" CONFIG_DIR="$1"
MOVIEPILOT_AUTO_UPDATE="$2" MOVIEPILOT_AUTO_UPDATE="$2"
INSTALL_RESULT="$3" INSTALL_RESULT="$3"
MOVIEPILOT_UPDATE_DEV="$4"
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN= PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
source {UPDATER!s} source {UPDATER!s}
INFO() {{ :; }} INFO() {{ :; }}
@@ -825,7 +835,7 @@ def test_updater_exposes_explicit_result(
) )
result = subprocess.run( 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, text=True,
capture_output=True, capture_output=True,
check=True, check=True,
+48
View File
@@ -32,6 +32,8 @@ def _docker_manager(monkeypatch, tmp_path: Path):
"FRONTEND_PATH": tmp_path / "public", "FRONTEND_PATH": tmp_path / "public",
"VENV_PATH": tmp_path / "venv", "VENV_PATH": tmp_path / "venv",
"UV_BIN": tmp_path / "uv", "UV_BIN": tmp_path / "uv",
"MOVIEPILOT_AUTO_UPDATE": False,
"AUTO_UPDATE_RESOURCE": True,
"PIP_PROXY": "", "PIP_PROXY": "",
"PROXY_HOST": "", "PROXY_HOST": "",
} }
@@ -52,6 +54,52 @@ def _response(payload, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: payload) 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): def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path) manager = _manager(monkeypatch, tmp_path)
logs = [] 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 assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True
def test_auto_update_mode_is_normalized(monkeypatch): @pytest.mark.parametrize("mode", ["release", "dev", " DEV ", "RELEASE"])
"""自动更新仅保留 true、dev 和 false 三种运行模式。""" def test_auto_update_mode_is_normalized(monkeypatch, mode):
"""旧模式规范化为布尔 true,已有的新 Dev 偏好不被覆盖。"""
updates = [] updates = []
monkeypatch.setattr( monkeypatch.setattr(
Settings, Settings,
@@ -549,14 +550,51 @@ def test_auto_update_mode_is_normalized(monkeypatch):
), ),
) )
assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false" config = Settings(MOVIEPILOT_AUTO_UPDATE=mode, MOVIEPILOT_UPDATE_DEV=False)
assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "true" assert config.MOVIEPILOT_AUTO_UPDATE is True
assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev" assert config.MOVIEPILOT_UPDATE_DEV is False
assert updates == [ 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(): def test_space_usage_default_path_does_not_read_fsid():
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2: with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
paths = [Path(tmp1), Path(tmp2)] paths = [Path(tmp1), Path(tmp2)]