From 84326e20de20df036ea61b54aea716273bc9db62 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 17:58:03 +0800 Subject: [PATCH] feat(system): add staged release updates --- app/adapters/system/update.py | 477 ++++++++++++++++++ app/api/endpoints/system.py | 79 ++- app/cli.py | 174 ++++--- app/runtime/config.py | 20 +- app/runtime/state.py | 130 ++--- app/scheduler.py | 13 + app/schemas/exports.py | 3 +- app/schemas/system.py | 28 +- docker/entrypoint.sh | 29 +- docker/update.sh | 124 +++-- docs/cli.md | 4 +- docs/mcp-api.md | 12 + docs/rules/03-commands.md | 2 + scripts/local_setup.py | 66 ++- skills/moviepilot-api/SKILL.md | 8 +- skills/moviepilot-update/SKILL.md | 41 +- skills/moviepilot-update/scripts/mp-update.py | 24 +- .../architecture/dependency-baseline.json | 23 +- tests/test_cli_auto_update.py | 185 ++++--- tests/test_docker_bootstrap.py | 71 ++- tests/test_lifecycle_shutdown.py | 38 +- tests/test_system_update_manager.py | 170 +++++++ tests/test_system_utils.py | 22 +- 23 files changed, 1393 insertions(+), 350 deletions(-) create mode 100644 app/adapters/system/update.py create mode 100644 tests/test_system_update_manager.py diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py new file mode 100644 index 000000000..69f0905a7 --- /dev/null +++ b/app/adapters/system/update.py @@ -0,0 +1,477 @@ +"""MoviePilot Release 后台检查、下载与待安装状态管理。""" + +from __future__ import annotations + +import hashlib +import json +import re +import stat +import subprocess +import threading +import zipfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Optional + +from app.adapters.network.http import RequestUtils +from app.foundation.singleton import SingletonClass +from app.foundation.version import compare_version +from app.foundation.environment import is_docker +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.thread import ThreadHelper +from app.schemas.system import SystemUpdateStatus +from version import APP_VERSION + + +class SystemUpdateManager(metaclass=SingletonClass): + """持久化更新状态,并保证同一时刻只有一个下载任务。""" + + _BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases" + _FRONTEND_RELEASE_API = ( + "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/tags/{tag}" + ) + _BACKEND_ARCHIVE_URL = ( + "https://github.com/jxxghp/MoviePilot/archive/refs/tags/{tag}.zip" + ) + _VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:[-.](?:alpha|beta|rc)\d*)?$", re.I) + _STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+$", re.I) + + def __init__(self) -> None: + self._lock = threading.RLock() + self._download_active = False + + @property + def _root(self) -> Path: + return Path(get_runtime_setting("TEMP_PATH")) / "moviepilot-update" + + @property + def _state_file(self) -> Path: + return self._root / "state.json" + + @property + def _install_file(self) -> Path: + return self._root / "install.json" + + @property + def _backend_archive(self) -> Path: + return self._root / "backend.zip" + + @property + def _frontend_archive(self) -> Path: + return self._root / "frontend.zip" + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + def _default_state(self) -> dict[str, Any]: + return SystemUpdateStatus(current_version=APP_VERSION).model_dump() + + def _read_state(self) -> dict[str, Any]: + try: + payload = json.loads(self._state_file.read_text(encoding="utf-8")) + if isinstance(payload, dict): + return {**self._default_state(), **payload, "current_version": APP_VERSION} + except (OSError, json.JSONDecodeError): + pass + return self._default_state() + + def _write_state(self, **changes: Any) -> dict[str, Any]: + with self._lock: + state = self._read_state() + state.update(changes) + state["current_version"] = APP_VERSION + state["progress"] = self._progress( + state.get("downloaded_bytes", 0), state.get("total_bytes", 0) + ) + validated = SystemUpdateStatus.model_validate(state).model_dump() + self._root.mkdir(parents=True, exist_ok=True) + temporary = self._state_file.with_suffix(".tmp") + temporary.write_text( + json.dumps(validated, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temporary.replace(self._state_file) + return validated + + @staticmethod + def _progress(downloaded: Any, total: Any) -> int: + try: + downloaded_value = max(0, int(downloaded)) + total_value = max(0, int(total)) + except (TypeError, ValueError): + return 0 + if total_value <= 0: + return 0 + return min(100, int(downloaded_value * 100 / total_value)) + + def get_status(self) -> SystemUpdateStatus: + """返回状态快照,并在更新完成后的新进程中清理安装终态。""" + with self._lock: + state = self._read_state() + target = str(state.get("version") or "") + if state.get("state") == "downloading" and not self._download_active: + state = self._write_state( + state="failed", + error="更新包下载因服务重启而中断,请重试", + can_update=True, + can_install=False, + ) + if state.get("state") == "installing" and target == APP_VERSION: + self._install_file.unlink(missing_ok=True) + state = self._write_state( + state="idle", + version=None, + frontend_version=None, + release_name=None, + release_notes=None, + published_at=None, + downloaded_bytes=0, + total_bytes=0, + error=None, + can_update=False, + can_install=False, + ) + return SystemUpdateStatus.model_validate(state) + + def check(self) -> SystemUpdateStatus: + """查询 GitHub 稳定版 v3 Release,并保留正在下载或待安装状态。""" + current = self.get_status() + if current.state in {"downloading", "ready", "installing"}: + return current + + try: + response = self._request().get_res(self._BACKEND_RELEASES_API) + if response is None or response.status_code != 200: + raise RuntimeError("GitHub Release 请求失败") + releases = response.json() + release = next( + ( + item + for item in releases + if isinstance(item, dict) + and not item.get("draft") + and not item.get("prerelease") + and self._STABLE_VERSION_PATTERN.fullmatch( + str(item.get("tag_name") or "") + ) + ), + None, + ) + if not release: + raise RuntimeError("未找到可用的 v3 稳定版本") + version = str(release["tag_name"]) + has_update = compare_version(version, "gt", APP_VERSION) is True + return SystemUpdateStatus.model_validate( + self._write_state( + state="available" if has_update else "idle", + version=version if has_update else None, + frontend_version=None, + release_name=str(release.get("name") or version) if has_update else None, + release_notes=str(release.get("body") or "") if has_update else None, + published_at=release.get("published_at") if has_update else None, + checked_at=self._now(), + downloaded_bytes=0, + total_bytes=0, + error=None, + can_update=has_update, + can_install=False, + ) + ) + except Exception as error: # 定时检查失败不应打扰用户,下载失败才进入可见错误态 + logger.warning(f"检查 MoviePilot 更新失败: {error}") + return SystemUpdateStatus.model_validate( + self._write_state( + state="idle", + checked_at=self._now(), + error=str(error), + can_update=False, + can_install=False, + ) + ) + + def start_download(self) -> SystemUpdateStatus: + """启动唯一后台下载线程,并立即返回下载中状态。""" + with self._lock: + state = self.get_status() + if state.state == "ready": + return state + if state.state == "downloading" and self._download_active: + return state + if state.state != "available" or not state.version: + state = self.check() + if state.state != "available" or not state.version: + return state + + self._backend_archive.unlink(missing_ok=True) + self._frontend_archive.unlink(missing_ok=True) + self._write_state( + state="downloading", + downloaded_bytes=0, + total_bytes=0, + error=None, + can_update=False, + can_install=False, + ) + self._download_active = True + try: + ThreadHelper().submit(self._download_update, state.version) + except RuntimeError as error: + self._download_active = False + return SystemUpdateStatus.model_validate( + self._write_state( + state="failed", + error=f"无法启动更新包下载:{error}", + can_update=True, + can_install=False, + ) + ) + return self.get_status() + + def request_install(self) -> tuple[bool, str]: + """校验待安装文件并写入启动阶段消费的安装意图。""" + with self._lock: + state = self.get_status() + if state.state != "ready" or not state.version: + return False, "更新包尚未下载完成" + try: + backend_sha256 = self._sha256(self._backend_archive) + frontend_sha256 = self._sha256(self._frontend_archive) + prepared = self._read_prepared_manifest() + if backend_sha256 != prepared.get("backend_sha256"): + raise RuntimeError("后端更新包校验失败") + if frontend_sha256 != prepared.get("frontend_sha256"): + raise RuntimeError("前端更新包校验失败") + self._install_file.write_text( + json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self._write_state(state="installing", can_install=False, error=None) + return True, "更新包已就绪,正在重启安装" + except (OSError, RuntimeError, json.JSONDecodeError) as error: + self._write_state(state="failed", error=str(error), can_install=False) + return False, str(error) + + def cancel_install(self, reason: str) -> None: + """重启请求失败时撤销安装意图,避免下次普通启动意外安装。""" + with self._lock: + self._install_file.unlink(missing_ok=True) + self._write_state( + state="ready", + error=reason, + can_update=False, + can_install=True, + ) + + def _request(self) -> RequestUtils: + return RequestUtils( + proxies=get_runtime_setting("PROXY"), + headers=get_runtime_setting("GITHUB_HEADERS"), + timeout=60, + ) + + def _download_update(self, version: str) -> None: + try: + downloaded = 0 + downloaded, backend_total = self._download_file( + self._proxied(self._BACKEND_ARCHIVE_URL.format(tag=version)), + self._backend_archive, + downloaded, + 0, + ) + frontend_version = self._validate_backend_archive(version) + frontend_release = self._fetch_frontend_release(frontend_version) + frontend_asset = next( + ( + item + for item in frontend_release.get("assets") or [] + if item.get("name") == "dist.zip" and item.get("browser_download_url") + ), + None, + ) + if not frontend_asset: + raise RuntimeError(f"前端 {frontend_version} 缺少 dist.zip 发布资产") + frontend_total = int(frontend_asset.get("size") or 0) + total = backend_total + frontend_total + self._write_state( + frontend_version=frontend_version, + downloaded_bytes=downloaded, + total_bytes=total, + ) + downloaded, _ = self._download_file( + self._proxied(str(frontend_asset["browser_download_url"])), + self._frontend_archive, + downloaded, + total, + ) + self._validate_frontend_archive(frontend_version) + expected_digest = str(frontend_asset.get("digest") or "") + frontend_sha256 = self._sha256(self._frontend_archive) + if expected_digest.startswith("sha256:") and frontend_sha256 != expected_digest.removeprefix("sha256:"): + raise RuntimeError("前端更新包与 GitHub Release 摘要不一致") + + if not is_docker(): + self._prepare_local_backend_ref(version) + + prepared = { + "version": version, + "frontend_version": frontend_version, + "backend_archive": str(self._backend_archive), + "frontend_archive": str(self._frontend_archive), + "backend_sha256": self._sha256(self._backend_archive), + "frontend_sha256": frontend_sha256, + "prepared_at": self._now(), + } + (self._root / "prepared.json").write_text( + json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self._write_state( + state="ready", + downloaded_bytes=downloaded, + total_bytes=max(total, downloaded), + error=None, + can_update=False, + can_install=True, + ) + logger.info(f"MoviePilot {version} 更新包已下载完成,等待用户确认重启") + except Exception as error: # 后台线程必须把所有失败沉淀为可查询状态 + logger.error(f"下载 MoviePilot 更新包失败: {error}") + self._write_state( + state="failed", error=str(error), can_update=True, can_install=False + ) + finally: + with self._lock: + self._download_active = False + + def _download_file( + self, url: str, destination: Path, downloaded_before: int, total_hint: int + ) -> tuple[int, int]: + temporary = destination.with_suffix(".part") + temporary.unlink(missing_ok=True) + with self._request().get_stream(url) as response: + if response is None or response.status_code != 200: + raise RuntimeError(f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}") + content_length = int(response.headers.get("content-length") or 0) + total = total_hint or content_length + current = downloaded_before + with temporary.open("wb") as output: + for chunk in response.iter_content(chunk_size=256 * 1024): + if not chunk: + continue + output.write(chunk) + current += len(chunk) + self._write_state(downloaded_bytes=current, total_bytes=total) + temporary.replace(destination) + return current, content_length + + def _fetch_frontend_release(self, version: str) -> dict[str, Any]: + response = self._request().get_res( + self._FRONTEND_RELEASE_API.format(tag=version) + ) + if response is None or response.status_code != 200: + raise RuntimeError(f"无法获取前端 {version} Release") + payload = response.json() + if not isinstance(payload, dict): + raise RuntimeError("前端 Release 返回格式异常") + return payload + + def _validate_backend_archive(self, version: str) -> str: + with zipfile.ZipFile(self._backend_archive) as archive: + self._validate_zip_members(archive) + version_name = next( + (name for name in archive.namelist() if name.count("/") == 1 and name.endswith("/version.py")), + None, + ) + if not version_name: + raise RuntimeError("后端更新包缺少 version.py") + version_source = archive.read(version_name).decode("utf-8") + app_match = re.search(r"^APP_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M) + frontend_match = re.search(r"^FRONTEND_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M) + if not app_match or app_match.group(1) != version: + raise RuntimeError("后端更新包版本与目标 Release 不一致") + if not frontend_match or not self._VERSION_PATTERN.fullmatch(frontend_match.group(1)): + raise RuntimeError("后端更新包声明的前端版本无效") + required = ("pyproject.toml", "uv.lock") + names = archive.namelist() + if any(not any(name.endswith(f"/{item}") for name in names) for item in required): + raise RuntimeError("后端更新包缺少依赖锁定文件") + return frontend_match.group(1) + + def _validate_frontend_archive(self, version: str) -> None: + with zipfile.ZipFile(self._frontend_archive) as archive: + self._validate_zip_members(archive) + names = set(archive.namelist()) + if "dist/index.html" not in names or "dist/version.txt" not in names: + raise RuntimeError("前端更新包结构无效") + archived_version = archive.read("dist/version.txt").decode("utf-8").strip() + if archived_version != version: + raise RuntimeError("前端更新包版本与后端声明不一致") + + @staticmethod + def _validate_zip_members(archive: zipfile.ZipFile) -> None: + for item in archive.infolist(): + path = PurePosixPath(item.filename) + file_type = (item.external_attr >> 16) & 0o170000 + if ( + path.is_absolute() + or ".." in path.parts + or file_type == stat.S_IFLNK + ): + raise RuntimeError("更新包包含不安全路径") + + def _read_prepared_manifest(self) -> dict[str, Any]: + payload = json.loads((self._root / "prepared.json").read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError("更新包清单格式无效") + return payload + + @staticmethod + def _prepare_local_backend_ref(version: str) -> None: + """本地 CLI 在下载阶段获取标签,使重启后的代码切换不再联网。""" + root = Path(__file__).resolve().parents[3] + if not (root / ".git").is_dir(): + raise RuntimeError("本地安装目录不是 Git 仓库,无法准备 Release 更新") + try: + worktree = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + if worktree.stdout.strip(): + raise RuntimeError("本地源码存在未提交改动,无法准备 Release 更新") + subprocess.run( + ["git", "fetch", "--no-tags", "origin", "tag", version], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=180, + ) + subprocess.run( + ["git", "rev-parse", "--verify", f"{version}^{{commit}}"], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise RuntimeError(f"无法准备本地 Release 标签 {version}") from error + + @staticmethod + def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + @staticmethod + def _proxied(url: str) -> str: + proxy = str(get_runtime_setting("GITHUB_PROXY") or "").strip() + return f"{proxy}{url}" if proxy else url + + +system_update_manager = SystemUpdateManager() diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 91f35ee39..fbce88a38 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -28,6 +28,7 @@ from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyn from app.schemas.system import RuleTestData as _SchemaRuleTestData from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData +from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus from app.schemas.system import TorrentInfo as _SchemaTorrentInfo from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter @@ -74,6 +75,7 @@ from app.foundation.crypto import HashUtils from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.adapters.system import rust as rust_accel +from app.adapters.system.update import system_update_manager from app.application.security.url import SecurityUtils from app.application.network import NetworkTestService from app.foundation.url import UrlUtils @@ -1495,22 +1497,81 @@ def restart_system(_: ApiPrincipal = Depends(get_current_active_superuser)): return _SchemaResponse(success=ret, message=msg) -@router.post("/upgrade", summary="升级并重启系统", response_model=_SchemaResponse[None]) +@router.post("/upgrade", summary="Dev 更新并重启系统", response_model=_SchemaResponse[None]) def upgrade_system( mode: Annotated[str | None, Body()] = None, _: ApiPrincipal = Depends(get_current_active_superuser), ): - """ - 触发系统升级并重启(仅管理员) - - - 当前已开启自动升级时:直接重启,由启动流程完成升级。 - - 当前未开启自动升级时:写入一次性升级标记,本次重启后仅执行一次升级。 - """ + """保留 Dev 更新入口;Release 更新必须使用后台下载与确认安装流程。""" + if str(mode or "").strip().lower() != "dev": + return _SchemaResponse( + success=False, + message="Release 更新请使用 /system/update/check、download 和 install 接口", + ) if not SystemHelper.can_restart(): return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!") + success, message = SystemHelper.upgrade_dev() + return _SchemaResponse(success=success, message=message) - ret, msg = SystemHelper.upgrade(mode=mode or "release") - return _SchemaResponse(success=ret, message=msg) + +@router.get( + "/update/status", + summary="查询系统更新状态", + response_model=_SchemaResponse[_SchemaSystemUpdateStatus], +) +def system_update_status( + _: ApiPrincipal = Depends(get_current_active_superuser), +): + """返回后台检查、下载或待安装状态(仅管理员)。""" + return _SchemaResponse(success=True, data=system_update_manager.get_status()) + + +@router.post( + "/update/check", + summary="立即检查系统更新", + response_model=_SchemaResponse[_SchemaSystemUpdateStatus], +) +def check_system_update( + _: ApiPrincipal = Depends(get_current_active_superuser), +): + """立即查询 GitHub Release(仅管理员)。""" + return _SchemaResponse(success=True, data=system_update_manager.check()) + + +@router.post( + "/update/download", + summary="后台下载系统更新", + response_model=_SchemaResponse[_SchemaSystemUpdateStatus], +) +def download_system_update( + _: ApiPrincipal = Depends(get_current_active_superuser), +): + """启动后台下载并立即返回当前状态(仅管理员)。""" + if not SystemHelper.can_restart(): + return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!") + status = system_update_manager.start_download() + return _SchemaResponse(success=status.state != "failed", data=status, message=status.error) + + +@router.post( + "/update/install", + summary="确认重启安装系统更新", + response_model=_SchemaResponse[None], +) +def install_system_update( + _: ApiPrincipal = Depends(get_current_active_superuser), +): + """确认消费已校验更新包,并重启进入安装阶段(仅管理员)。""" + if not SystemHelper.can_restart(): + return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!") + prepared, message = system_update_manager.request_install() + if not prepared: + return _SchemaResponse(success=False, message=message) + ret, msg = SystemHelper.restart() + if not ret: + system_update_manager.cancel_install(msg) + return _SchemaResponse(success=False, message=msg) + return _SchemaResponse(success=True, message=message) @router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None]) diff --git a/app/cli.py b/app/cli.py index 988e337b6..e8904426a 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,3 +1,4 @@ +import hashlib import json import os import re @@ -10,7 +11,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, Optional, get_args, get_origin from urllib.error import HTTPError, URLError from urllib.parse import urlencode -from urllib.request import ProxyHandler, Request, build_opener, urlopen +from urllib.request import Request, urlopen import click import psutil @@ -35,10 +36,8 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt" HEALTH_PATH = "/api/v1/system/global" HEALTH_TOKEN = "moviepilot" FRONTEND_HEALTH_PATH = "/version.txt" -BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases" LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"} MANAGED_ACTIVE_STATES = {"running", "starting"} -AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"} MASKED_FIELDS = { "API_TOKEN", "DB_POSTGRESQL_PASSWORD", @@ -48,6 +47,9 @@ MASKED_FIELDS = { } MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY") CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} +PREPARED_UPDATE_ROOT = settings.TEMP_PATH / "moviepilot-update" +PREPARED_UPDATE_MANIFEST = PREPARED_UPDATE_ROOT / "install.json" +PREPARED_UPDATE_STATE = PREPARED_UPDATE_ROOT / "state.json" def _repo_root() -> Path: @@ -221,49 +223,6 @@ def _release_prefix(version: Optional[str]) -> str: return matched.group(1) if matched else "v2" -def _release_sort_key(tag: str) -> tuple[int, ...]: - return tuple(int(part) for part in re.findall(r"\d+", tag)) - - -def _github_api_json(url: str, *, repo: str) -> Any: - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": settings.USER_AGENT, - } - headers.update(settings.REPO_GITHUB_HEADERS(repo)) - opener = build_opener(ProxyHandler(settings.PROXY or {})) - request = Request(url=url, headers=headers, method="GET") - - try: - with opener.open(request, timeout=10.0) as response: - return json.loads(response.read().decode("utf-8")) - except HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"访问 GitHub API 失败(HTTP {exc.code}): {detail or url}") from exc - except URLError as exc: - raise RuntimeError(f"访问 GitHub API 失败:{exc.reason}") from exc - except json.JSONDecodeError as exc: - raise RuntimeError(f"GitHub API 返回了无法解析的响应:{url}") from exc - - -def _latest_release_tag(url: str, *, repo: str, prefix: str) -> Optional[str]: - payload = _github_api_json(url, repo=repo) - if not isinstance(payload, list): - raise RuntimeError(f"GitHub API 返回格式异常:{url}") - - matched_tags = [] - for item in payload: - if not isinstance(item, dict): - continue - tag_name = str(item.get("tag_name") or "").strip() - if tag_name.startswith(f"{prefix}."): - matched_tags.append(tag_name) - - if not matched_tags: - return None - return sorted(matched_tags, key=_release_sort_key)[-1] - - def _git_current_branch() -> Optional[str]: try: branch = subprocess.check_output( @@ -277,33 +236,120 @@ def _git_current_branch() -> Optional[str]: def _auto_update_mode() -> str: - one_shot_mode = SystemHelper.consume_one_shot_update_mode() - if one_shot_mode: - return one_shot_mode - return SystemHelper.get_auto_update_mode() + if SystemHelper.consume_one_shot_dev_update(): + return "dev" + return str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _mark_prepared_update_failed(message: str) -> None: + state = _read_json_file(PREPARED_UPDATE_STATE) or {} + state.update( + { + "state": "failed", + "error": message, + "can_update": True, + "can_install": False, + } + ) + _write_json_file(PREPARED_UPDATE_STATE, state) + _clear_json_file(PREPARED_UPDATE_MANIFEST) + + +def _apply_prepared_release_update() -> bool: + """本地 CLI 重启时离线安装已校验的 Release;返回是否发现安装意图。""" + manifest = _read_json_file(PREPARED_UPDATE_MANIFEST) + if not manifest: + return False + + try: + version = str(manifest.get("version") or "").strip() + frontend_version = str(manifest.get("frontend_version") or "").strip() + backend_archive = Path(str(manifest.get("backend_archive") or "")) + frontend_archive = Path(str(manifest.get("frontend_archive") or "")) + if not version or not frontend_version: + raise RuntimeError("更新包清单缺少版本信息") + if ( + not backend_archive.is_file() + or _file_sha256(backend_archive) != manifest.get("backend_sha256") + ): + raise RuntimeError("后端更新包校验失败") + if ( + not frontend_archive.is_file() + or _file_sha256(frontend_archive) != manifest.get("frontend_sha256") + ): + raise RuntimeError("前端更新包校验失败") + + update_command = [ + sys.executable, + str(_repo_root() / "scripts" / "local_setup.py"), + "update", + "all", + "--ref", + version, + "--offline-backend", + "--frontend-version", + frontend_version, + "--frontend-archive", + str(frontend_archive), + "--skip-resources", + "--venv", + str(_repo_root() / "venv"), + "--config-dir", + str(settings.CONFIG_PATH), + ] + click.echo(f"安装已下载并校验的 MoviePilot {version} 更新包") + result = subprocess.run( + update_command, + cwd=str(_repo_root()), + env=os.environ.copy(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if result.returncode != 0: + lines = [line for line in (result.stdout or "").splitlines() if line.strip()] + detail = lines[-1] if lines else "未知错误" + raise RuntimeError(detail) + + _clear_json_file(PREPARED_UPDATE_MANIFEST) + click.echo("已下载的 Release 更新安装完成") + except (OSError, RuntimeError, ValueError) as error: + message = f"本地 Release 更新安装失败:{error}" + _mark_prepared_update_failed(message) + _warn(f"{message},继续使用当前版本启动") + return True def _resolve_auto_update_targets(mode: str) -> Optional[str]: + if mode != "dev": + return None backend_prefix = _release_prefix(APP_VERSION) - - if mode == "dev": - current_branch = _git_current_branch() - backend_ref = "latest" - if not current_branch or current_branch == "HEAD": - # 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。 - backend_ref = backend_prefix - else: - backend_ref = _latest_release_tag( - BACKEND_RELEASES_API, - repo="jxxghp/MoviePilot", - prefix=backend_prefix, - ) + current_branch = _git_current_branch() + backend_ref = "latest" + if not current_branch or current_branch == "HEAD": + # 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。 + backend_ref = backend_prefix return backend_ref def _best_effort_auto_update() -> None: + if _apply_prepared_release_update(): + return + mode = _auto_update_mode() - if mode not in AUTO_UPDATE_ENABLED_VALUES: + # Release 更新先在后台下载并经用户确认;这里只保留开发版分支跟踪。 + if mode != "dev": return try: diff --git a/app/runtime/config.py b/app/runtime/config.py index c764a2d7f..0ca305caf 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -317,8 +317,8 @@ class ConfigModel(BaseModel): ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403" # ==================== 系统升级配置 ==================== - # 重启自动升级 - MOVIEPILOT_AUTO_UPDATE: str = "release" + # 开发版仍可在启动时跟踪 v3 分支;Release 更新由后台更新服务管理。 + MOVIEPILOT_AUTO_UPDATE: str = "false" # 自动检查和更新站点资源包(站点索引、认证等) AUTO_UPDATE_RESOURCE: bool = True @@ -899,6 +899,22 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): if not isinstance(data, dict): return data + # Release 已迁移到后台状态机,历史 release/true 不能继续启用启动时更新。 + if "MOVIEPILOT_AUTO_UPDATE" in data: + original_update_mode = data["MOVIEPILOT_AUTO_UPDATE"] + normalized_update_mode = ( + "dev" + if str(original_update_mode or "").strip().lower() == "dev" + 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 + # 处理 API_TOKEN 特殊验证 if "API_TOKEN" in data: converted_value, needs_update = cls.validate_api_token( diff --git a/app/runtime/state.py b/app/runtime/state.py index ac5cf149c..337e1346f 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -23,7 +23,6 @@ class SystemHelper(ConfigReloadMixin): """ 系统工具类,提供系统相关的操作和判断 """ - AUTO_UPDATE_ENABLED_VALUES = {"release", "dev"} CONFIG_WATCH = { "DEBUG", "LOG_LEVEL", @@ -36,7 +35,7 @@ class SystemHelper(ConfigReloadMixin): __system_flag_file = "/var/log/nginx/__moviepilot__" __local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json" __local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log" - __one_shot_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_update" + __one_shot_dev_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_dev_update" __docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart" __graceful_shutdown_monitor_lock = threading.Lock() __graceful_shutdown_monitor: Optional[threading.Thread] = None @@ -97,94 +96,41 @@ class SystemHelper(ConfigReloadMixin): return False @staticmethod - def normalize_auto_update_mode(mode: Optional[str]) -> str: - """ - 统一自动升级模式值,兼容历史 true 表示 release。 - """ - normalized = str(mode or "").strip().lower() - return "release" if normalized == "true" else normalized - - @staticmethod - def get_auto_update_mode() -> str: - """ - 获取当前配置中的自动升级模式。 - """ - return SystemHelper.normalize_auto_update_mode( - settings.MOVIEPILOT_AUTO_UPDATE - ) - - @staticmethod - def is_auto_update_enabled(mode: Optional[str] = None) -> bool: - """ - 判断给定模式或当前配置是否启用了启动时自动升级。 - """ - effective_mode = ( - SystemHelper.get_auto_update_mode() - if mode is None - else SystemHelper.normalize_auto_update_mode(mode) - ) - return effective_mode in SystemHelper.AUTO_UPDATE_ENABLED_VALUES - - @staticmethod - def queue_one_shot_update(mode: str = "release") -> Tuple[bool, str]: - """ - 写入一次性升级标记,供重启后的启动流程消费。 - """ - effective_mode = SystemHelper.normalize_auto_update_mode(mode) - if effective_mode not in SystemHelper.AUTO_UPDATE_ENABLED_VALUES: - return False, "升级模式仅支持 release 或 dev" - + def queue_one_shot_dev_update() -> Tuple[bool, str]: + """写入一次性 Dev 更新标记,供本次重启的启动流程消费。""" try: - SystemHelper.__one_shot_update_flag_file.parent.mkdir( + SystemHelper.__one_shot_dev_update_flag_file.parent.mkdir( parents=True, exist_ok=True ) - SystemHelper.__one_shot_update_flag_file.write_text( - effective_mode, encoding="utf-8" + SystemHelper.__one_shot_dev_update_flag_file.write_text( + "dev", encoding="utf-8" ) - logger.info(f"已写入一次性升级标记,模式: {effective_mode}") return True, "" except OSError as err: - logger.error(f"写入一次性升级标记失败: {err}") - return False, f"写入一次性升级标记失败:{err}" + logger.error(f"写入一次性 Dev 更新标记失败: {err}") + return False, f"写入一次性 Dev 更新标记失败:{err}" @staticmethod - def consume_one_shot_update_mode() -> Optional[str]: - """ - 读取并清除一次性升级标记,避免后续启动重复执行。 - """ - path = SystemHelper.__one_shot_update_flag_file + def consume_one_shot_dev_update() -> bool: + """读取并删除一次性 Dev 更新标记,确保普通重启不会重复更新。""" + path = SystemHelper.__one_shot_dev_update_flag_file if not path.exists(): - return None - - try: - raw_mode = path.read_text(encoding="utf-8", errors="replace") - except OSError as err: - logger.warning(f"读取一次性升级标记失败: {err}") - raw_mode = "" - + return False try: + mode = path.read_text(encoding="utf-8", errors="replace").strip().lower() path.unlink(missing_ok=True) except OSError as err: - logger.warning(f"删除一次性升级标记失败: {err}") - - effective_mode = SystemHelper.normalize_auto_update_mode(raw_mode) - if effective_mode not in SystemHelper.AUTO_UPDATE_ENABLED_VALUES: - if raw_mode: - logger.warning(f"忽略无效的一次性升级模式: {raw_mode}") - return None - - logger.info(f"检测到一次性升级标记,模式: {effective_mode}") - return effective_mode + logger.warning(f"消费一次性 Dev 更新标记失败: {err}") + return False + return mode == "dev" @staticmethod - def clear_one_shot_update_flag() -> None: - """ - 删除一次性升级标记。 - """ + def clear_one_shot_dev_update() -> None: + """重启失败时撤销尚未消费的一次性 Dev 更新。""" try: - SystemHelper.__one_shot_update_flag_file.unlink(missing_ok=True) + SystemHelper.__one_shot_dev_update_flag_file.unlink(missing_ok=True) except OSError as err: - logger.warning(f"删除一次性升级标记失败: {err}") + logger.warning(f"清理一次性 Dev 更新标记失败: {err}") @staticmethod def _spawn_local_restart_helper() -> None: @@ -332,32 +278,18 @@ class SystemHelper(ConfigReloadMixin): return SystemHelper._docker_api_restart() @staticmethod - def upgrade(mode: str = "release") -> Tuple[bool, str]: - """ - 触发升级并重启。 - - - 已开启自动升级时,直接重启,沿用当前配置。 - - 未开启自动升级时,写入一次性升级标记,供下次启动时执行升级。 - """ - current_mode = SystemHelper.get_auto_update_mode() - if SystemHelper.is_auto_update_enabled(current_mode): - ret, msg = SystemHelper.restart() - if not ret: - return ret, msg - if current_mode == "dev": - return True, "已检测到自动升级模式 dev,正在重启并执行升级" - return True, "已检测到自动升级已开启,正在重启并执行升级" - - queued, message = SystemHelper.queue_one_shot_update(mode) - if not queued: - return False, message - - ret, msg = SystemHelper.restart() + def upgrade_dev() -> Tuple[bool, str]: + """保留原 Dev 模式:重启后跟踪当前 v3 开发分支。""" + configured_mode = str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower() + if configured_mode != "dev": + queued, message = SystemHelper.queue_one_shot_dev_update() + if not queued: + return False, message + ret, message = SystemHelper.restart() if not ret: - SystemHelper.clear_one_shot_update_flag() - return ret, msg - effective_mode = SystemHelper.normalize_auto_update_mode(mode) - return True, f"已安排一次性 {effective_mode} 升级并重启" + SystemHelper.clear_one_shot_dev_update() + return False, message + return True, "已安排 Dev 更新并重启" @staticmethod def _start_graceful_shutdown_monitor(): diff --git a/app/scheduler.py b/app/scheduler.py index 72f47e7f2..601403839 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -42,6 +42,7 @@ from app.application.mediaserver import get_mediaserver_configs from app.application.messaging.message import MessageHelper from app.runtime.progress import AsyncProgressHelper, ProgressHelper from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.update import system_update_manager from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.runtime.log import logger from app.schemas.message import Message @@ -556,6 +557,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"), JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"), JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"), + JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system"), ]).runtime_states() for job_id, job in self._jobs.items(): self._assign_job_generation(job_id, job) @@ -777,6 +779,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): kwargs={"job_id": "plugin_market_refresh"}, ) + # 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。 + self._scheduler.add_job( + self.start, + "interval", + id="system_update_check", + name="检查系统更新", + hours=6, + next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=1), + kwargs={"job_id": "system_update_check"}, + ) + # 订阅日历缓存 self._scheduler.add_job( self.start, diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 4b589f86e..66200ea2a 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -364,6 +364,7 @@ SCHEMA_EXPORTS = { 'SystemErrorEventData': ('app.schemas.event', 'SystemErrorEventData'), 'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'), 'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'), + 'SystemUpdateStatus': ('app.schemas.system', 'SystemUpdateStatus'), 'TMDbException': ('app.schemas.exception', 'TMDbException'), 'Tag': ('app.schemas.context', 'Tag'), 'TimeData': ('app.schemas.common', 'TimeData'), @@ -448,7 +449,7 @@ SCHEMA_CONFLICTS = { 'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'], 'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], 'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'], - 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'], + 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.mcp'], 'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'], 'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'], 'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'], diff --git a/app/schemas/system.py b/app/schemas/system.py index 047e5fe6f..d5935e51c 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional, Any +from typing import Optional, Any, Literal from pydantic import BaseModel, Field, field_validator @@ -142,6 +142,32 @@ class SystemEnvironmentUpdateData(BaseModel): failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict) +class SystemUpdateStatus(BaseModel): + """主程序后台更新的可恢复状态快照。""" + + state: Literal[ + "idle", + "available", + "downloading", + "ready", + "installing", + "failed", + ] = "idle" + current_version: str + version: Optional[str] = None + frontend_version: Optional[str] = None + release_name: Optional[str] = None + release_notes: Optional[str] = None + published_at: Optional[str] = None + checked_at: Optional[str] = None + downloaded_bytes: int = 0 + total_bytes: int = 0 + progress: int = 0 + error: Optional[str] = None + can_update: bool = False + can_install: bool = False + + class PluginMarketSyncData(BaseModel): """Wiki 插件市场仓库同步结果。""" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 120513fb9..882b4d833 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -111,7 +111,7 @@ function load_config_from_app_env() { ["GITHUB_PROXY"]="" ["PROXY_HOST"]="" ["GITHUB_TOKEN"]="" - ["MOVIEPILOT_AUTO_UPDATE"]="release" + ["MOVIEPILOT_AUTO_UPDATE"]="false" ["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true" ["MOVIEPILOT_FORCE_CHOWN"]="false" ["MOVIEPILOT_SAFE_MODE"]="false" @@ -544,23 +544,15 @@ function correct_file_permissions() { load_config_from_app_env apply_package_cache_env -# 一次性升级标记仅影响本次启动,避免把临时升级模式带入运行中的 Python 进程 -ONE_SHOT_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_update" -ONE_SHOT_UPDATE_APPLIED="false" +# Dev 手动更新仍沿用一次性标记;Release 安装只消费已下载并校验的清单。 +ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update" +ONE_SHOT_DEV_UPDATE="false" MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}" -if [ -f "${ONE_SHOT_UPDATE_FLAG}" ]; then - ONE_SHOT_UPDATE_MODE="$(tr -d '\r\n' < "${ONE_SHOT_UPDATE_FLAG}" | tr '[:upper:]' '[:lower:]')" - rm -f "${ONE_SHOT_UPDATE_FLAG}" - if [ "${ONE_SHOT_UPDATE_MODE}" = "true" ]; then - ONE_SHOT_UPDATE_MODE="release" - fi - if [ "${ONE_SHOT_UPDATE_MODE}" = "release" ] || [ "${ONE_SHOT_UPDATE_MODE}" = "dev" ]; then - INFO "检测到一次性升级标记,本次启动将执行 ${ONE_SHOT_UPDATE_MODE} 升级..." - MOVIEPILOT_AUTO_UPDATE="${ONE_SHOT_UPDATE_MODE}" - ONE_SHOT_UPDATE_APPLIED="true" - elif [ -n "${ONE_SHOT_UPDATE_MODE}" ]; then - WARN "检测到无效的一次性升级模式:${ONE_SHOT_UPDATE_MODE},已忽略" - fi +if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then + rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}" + MOVIEPILOT_AUTO_UPDATE="dev" + ONE_SHOT_DEV_UPDATE="true" + INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支" fi # 使用env配置渲染 nginx 配置 @@ -583,10 +575,9 @@ if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then else MOVIEPILOT_UPDATE_RESULT="noop" fi -if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then +if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" fi - if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。" diagnostic_keepalive 1 diff --git a/docker/update.sh b/docker/update.sh index be483747a..42df18fe8 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -31,6 +31,26 @@ PUBLIC_DIR=/public UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__" UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__" UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__" +PREPARED_UPDATE_ROOT="${CONFIG_DIR}/temp/moviepilot-update" +PREPARED_UPDATE_MANIFEST="${PREPARED_UPDATE_ROOT}/install.json" +PREPARED_UPDATE_STATE="${PREPARED_UPDATE_ROOT}/state.json" + +function mark_prepared_update_failed() { + local message="$1" + local temporary_state="${PREPARED_UPDATE_STATE}.tmp.$$" + mkdir -p "${PREPARED_UPDATE_ROOT}" + if [ -f "${PREPARED_UPDATE_STATE}" ]; then + jq --arg error "${message}" \ + '.state = "failed" | .error = $error | .can_update = true | .can_install = false' \ + "${PREPARED_UPDATE_STATE}" > "${temporary_state}" + else + jq -n --arg error "${message}" \ + '{state: "failed", error: $error, can_update: true, can_install: false}' \ + > "${temporary_state}" + fi + mv -f "${temporary_state}" "${PREPARED_UPDATE_STATE}" + rm -f "${PREPARED_UPDATE_MANIFEST}" +} function apply_package_cache_env() { PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}" @@ -352,11 +372,19 @@ function swap_staged_payload() { # 下载程序资源,$1: 后端版本路径 function install_backend_and_download_resources() { # 更新后端程序 - if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then + if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then + if ! busybox unzip -q "${PREPARED_BACKEND_ARCHIVE}" -d "${TMP_PATH}"; then + ERROR "已准备的后端更新包解压失败" + return 1 + fi + if [ -e "${TMP_PATH}"/MoviePilot-* ]; then + mv "${TMP_PATH}"/MoviePilot-* "${TMP_PATH}/App" || return 1 + fi + elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then WARN "后端程序下载失败,继续使用旧的程序来启动..." return 1 fi - INFO "后端程序下载成功" + INFO "后端程序包准备成功" # 检查依赖清单,实际同步延后到所有运行载荷准备完成之后。 INFO "→ 检查依赖变化..." @@ -373,7 +401,10 @@ function install_backend_and_download_resources() { fi # 如果是"heads/v3.zip",则查找v3开头的最新版本号 - if [[ "${1}" == "heads/v3.zip" ]]; then + if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then + frontend_version="${PREPARED_FRONTEND_VERSION}" + INFO "已准备的前端版本号:${frontend_version}" + elif [[ "${1}" == "heads/v3.zip" ]]; then INFO "→ 正在获取前端最新版本号..." # 获取所有发布的版本列表,并筛选出以v3开头的版本号 releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v3\.") @@ -396,11 +427,16 @@ function install_backend_and_download_resources() { INFO "前端版本号:${frontend_version}" fi # 更新前端程序 - if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then + if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then + if ! busybox unzip -q "${PREPARED_FRONTEND_ARCHIVE}" -d "${TMP_PATH}"; then + ERROR "已准备的前端更新包解压失败" + return 1 + fi + elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then WARN "前端程序下载失败,继续使用旧的程序来启动..." return 1 fi - INFO "前端程序下载成功" + INFO "前端程序包准备成功" INFO "→ 正在准备插件和站点资源..." if ! stage_runtime_payload; then ERROR "更新载荷准备失败,当前程序未替换" @@ -648,7 +684,22 @@ function get_priority() { function run_moviepilot_update() { MOVIEPILOT_UPDATE_RESULT="noop" -if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "release" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]]; then +if [ -f "${PREPARED_UPDATE_MANIFEST}" ]; then + PREPARED_BACKEND_ARCHIVE=$(jq -r '.backend_archive // empty' "${PREPARED_UPDATE_MANIFEST}") + PREPARED_FRONTEND_ARCHIVE=$(jq -r '.frontend_archive // empty' "${PREPARED_UPDATE_MANIFEST}") + PREPARED_BACKEND_SHA256=$(jq -r '.backend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}") + PREPARED_FRONTEND_SHA256=$(jq -r '.frontend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}") + PREPARED_VERSION=$(jq -r '.version // empty' "${PREPARED_UPDATE_MANIFEST}") + PREPARED_FRONTEND_VERSION=$(jq -r '.frontend_version // empty' "${PREPARED_UPDATE_MANIFEST}") + if [ ! -f "${PREPARED_BACKEND_ARCHIVE}" ] || [ ! -f "${PREPARED_FRONTEND_ARCHIVE}" ] \ + || [ "$(sha256sum "${PREPARED_BACKEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_BACKEND_SHA256}" ] \ + || [ "$(sha256sum "${PREPARED_FRONTEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_FRONTEND_SHA256}" ]; then + ERROR "已准备的更新包校验失败,拒绝安装" + mark_prepared_update_failed "已准备的更新包校验失败" + MOVIEPILOT_UPDATE_RESULT="failed" + return 1 + fi + MOVIEPILOT_PREPARED_UPDATE="true" TMP_PATH=$(mktemp -d) if [ ! -d "${TMP_PATH}" ]; then # 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况 @@ -658,13 +709,38 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" fi mkdir -p /tmp/mp_update_path fi + CURL_OPTIONS="-sL" + if [ -n "${PROXY_HOST}" ]; then + CURL_OPTIONS="-sL -x ${PROXY_HOST}" + fi + if [ -n "${GITHUB_TOKEN}" ]; then + CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}" + else + CURL_HEADERS="" + fi + INFO "安装已下载并校验的 MoviePilot ${PREPARED_VERSION} 更新包" + if install_backend_and_download_resources "tags/${PREPARED_VERSION}.zip"; then + rm -f "${PREPARED_UPDATE_MANIFEST}" + else + mark_prepared_update_failed "已下载的 Release 更新安装失败" + MOVIEPILOT_UPDATE_RESULT="failed" + fi + if [ -d "${TMP_PATH}" ]; then + rm -rf "${TMP_PATH}" + fi +elif [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then + TMP_PATH=$(mktemp -d) + if [ ! -d "${TMP_PATH}" ]; then + TMP_PATH=/tmp/mp_update_path + rm -rf "${TMP_PATH}" + mkdir -p "${TMP_PATH}" + fi retries=0 while true; do if test_connectivity_github ${retries}; then break - else - retries=$((retries + 1)) fi + retries=$((retries + 1)) done INFO "Github:${GITHUB_LOG}" if [ -n "${GITHUB_TOKEN}" ]; then @@ -672,34 +748,12 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" else CURL_HEADERS="" fi - if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then - INFO "Dev 更新模式" - if ! install_backend_and_download_resources "heads/v3.zip"; then - MOVIEPILOT_UPDATE_RESULT="failed" - fi - else - INFO "Release 更新模式" - old_version=$(grep -m -1 "^\s*APP_VERSION\s*=\s*" /app/version.py | tr -d '\r\n' | awk -F'#' '{print $1}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - if [[ "${old_version}" == *APP_VERSION* ]]; then - current_version=$(echo "${old_version}" | sed -rn "s/APP_VERSION\s*=\s*['\"](.*)['\"]/\1/gp") - INFO "当前版本号:${current_version}" - if ! latest_v3=$(fetch_latest_v3_release); then - WARN "未找到任何v3后端版本,继续启动..." - else - INFO "最新的v3后端版本号:${latest_v3}" - # 使用版本号比较函数进行比较,并下载最新版本 - compare_versions "${current_version}" "${latest_v3}" - fi - else - WARN "当前版本号获取失败,继续启动..." - fi + INFO "Dev 更新模式" + if ! install_backend_and_download_resources "heads/v3.zip"; then + MOVIEPILOT_UPDATE_RESULT="failed" fi - if [ -d "${TMP_PATH}" ]; then - rm -rf "${TMP_PATH}" - fi -elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then - INFO "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release" + rm -rf "${TMP_PATH}" else - INFO "MOVIEPILOT_AUTO_UPDATE 变量设置错误" + INFO "没有待安装更新,按当前版本启动" fi } diff --git a/docs/cli.md b/docs/cli.md index d6e829692..3c89594e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -380,7 +380,9 @@ moviepilot version - `start` 会先启动后端,再启动前端 - `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置 -- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动 +- `MOVIEPILOT_AUTO_UPDATE` 默认关闭;仅 `dev` 保留启动前跟踪当前 v3 开发分支的行为,更新失败只告警,不阻断当前启动 +- Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,启动阶段只安装已下载且通过 SHA-256 校验的包 +- 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示 - 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启 - 前端默认监听 `NGINX_PORT`,默认值 `3000` - 后端默认监听 `PORT`,默认值 `3001` diff --git a/docs/mcp-api.md b/docs/mcp-api.md index ca2d1760b..bbce32ebe 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -130,6 +130,18 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 交互式接口文档 `/docs` 读取 `/api/v1/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。 +#### 系统更新 + +系统 Release 更新采用“检查、后台下载、确认安装”三阶段流程,以下接口均要求超级管理员登录态。后台每 6 小时自动检查一次稳定版 v3 GitHub Release;下载完成前不重启服务,安装接口只消费已下载并校验的后端与前端包。原 Dev 更新入口继续保留,但 `/system/upgrade` 只接受请求体 `"dev"`,不再处理 Release 更新。 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/system/update/status` | 查询 `idle`、`available`、`downloading`、`ready`、`installing` 或 `failed` 状态,以及版本、字节数和进度 | +| POST | `/api/v1/system/update/check` | 立即检查最新稳定版 v3 Release | +| POST | `/api/v1/system/update/download` | 后台下载并校验后端源码包与对应前端 `dist.zip`,立即返回当前状态 | +| POST | `/api/v1/system/update/install` | 对已准备完成的包再次校验后写入安装意图,并重启完成更新 | +| POST | `/api/v1/system/upgrade` | 保留 Dev 更新并重启,请求体只能为 `"dev"` | + #### 媒体识别 / 整理 媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。内置来源通过 `MediaSource` 提供 `themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic`、`bilibili`、`mangguodiscover`、`migu` 和 `tencentvideodiscover` 等常量;该列表不是插件来源白名单,插件可以注册符合 OpenAPI 格式约束的稳定扩展标识。`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 1a6694ecf..053e989d2 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -177,6 +177,8 @@ moviepilot update all --ref latest --frontend-version latest moviepilot update all --skip-resources ``` +`MOVIEPILOT_AUTO_UPDATE` defaults to `false`. Setting it to `dev` retains branch-tracking updates during `start/restart`; stable Release updates use the authenticated background check/download/install API flow and do not use this setting. + --- ## Local CLI — Startup on Boot diff --git a/scripts/local_setup.py b/scripts/local_setup.py index d45b6a961..00119b835 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -921,8 +921,20 @@ def install_node_runtime(node_version: str) -> Path: return node_bin -def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]: - version_tag, download_url = _resolve_frontend_release(frontend_version) +def install_frontend( + frontend_version: str, + node_version: str, + archive: Optional[Path] = None, +) -> dict[str, str]: + if archive: + version_tag = (frontend_version or "").strip() + if not version_tag: + raise RuntimeError("使用本地前端更新包时必须指定版本") + if not archive.is_file(): + raise RuntimeError(f"前端更新包不存在:{archive}") + download_url = "" + else: + version_tag, download_url = _resolve_frontend_release(frontend_version) node_bin = install_node_runtime(node_version) if _frontend_runtime_ready(version_tag): @@ -930,16 +942,29 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str] print_step(f"前端发布包已是最新版本:{version_tag}") return {"version": version_tag, "node": str(node_bin)} - print_step(f"下载前端发布包:{version_tag}") + print_step( + f"使用已下载的前端发布包:{version_tag}" + if archive + else f"下载前端发布包:{version_tag}" + ) with TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) archive_path = temp_path / "dist.zip" extract_dir = temp_path / "extract" - download_file(download_url, archive_path) + if archive: + shutil.copy2(archive, archive_path) + else: + download_file(download_url, archive_path) extract_archive(archive_path, extract_dir) dist_dir = extract_dir / "dist" if not dist_dir.exists(): raise RuntimeError("前端发布包中未找到 dist 目录") + packaged_version = (dist_dir / "version.txt") + if ( + not packaged_version.is_file() + or packaged_version.read_text(encoding="utf-8").strip() != version_tag + ): + raise RuntimeError("前端更新包版本与目标版本不一致") _remove_path(PUBLIC_DIR) shutil.move(str(dist_dir), str(PUBLIC_DIR)) @@ -3613,13 +3638,20 @@ def _ensure_git_clean() -> None: ) -def _update_backend_ref(ref: str) -> str: +def _update_backend_ref(ref: str, *, fetch: bool = True) -> str: if not (ROOT / ".git").exists(): raise RuntimeError("当前目录不是 Git 仓库,无法更新后端代码。") _ensure_git_clean() - print_step("获取远端更新") - run(["git", "fetch", "--tags", "origin"], cwd=ROOT) + if fetch: + print_step("获取远端更新") + run(["git", "fetch", "--tags", "origin"], cwd=ROOT) + else: + # Release 下载阶段已获取并验证标签,重启安装不得再次依赖网络。 + run( + ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + cwd=ROOT, + ) current_branch = _git_output("rev-parse", "--abbrev-ref", "HEAD") if ref == "latest": @@ -3637,10 +3669,15 @@ def _update_backend_ref(ref: str) -> str: def update_backend( - *, ref: str, python_bin: str, venv_dir: Path, recreate: bool + *, + ref: str, + python_bin: str, + venv_dir: Path, + recreate: bool, + fetch: bool = True, ) -> Path: ensure_services_stopped() - resolved_ref = _update_backend_ref(ref=ref) + resolved_ref = _update_backend_ref(ref=ref, fetch=fetch) venv_python = install_deps( python_bin=python_bin, venv_dir=venv_dir, recreate=recreate ) @@ -3860,6 +3897,15 @@ def build_parser() -> argparse.ArgumentParser: update_parser.add_argument( "--frontend-version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION" ) + update_parser.add_argument( + "--frontend-archive", + help="使用已经下载的前端 dist.zip,避免重启安装阶段再次联网", + ) + update_parser.add_argument( + "--offline-backend", + action="store_true", + help="不拉取远端,直接使用下载阶段准备好的本地 Git 标签", + ) update_parser.add_argument( "--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本" ) @@ -4066,11 +4112,13 @@ def main() -> int: python_bin=args.python, venv_dir=Path(args.venv), recreate=args.recreate, + fetch=not args.offline_backend, ) if args.target in {"frontend", "all"}: frontend_result = install_frontend( frontend_version=args.frontend_version, node_version=args.node_version, + archive=Path(args.frontend_archive) if args.frontend_archive else None, ) print_step(f"前端更新完成,版本:{frontend_result['version']}") if args.target == "all" and not args.skip_resources: diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index dbdee70d7..14cb25a9e 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -434,7 +434,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON | | GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` | -### System (24 endpoints) +### System (28 endpoints) | Method | Path | Description | |--------|------|-------------| @@ -448,7 +448,11 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) | | GET | `/api/v1/system/global/user` | User-related settings | | GET | `/api/v1/system/restart` | Restart system | -| POST | `/api/v1/system/upgrade` | Upgrade and restart system. Body: `"release"` or `"dev"` | +| POST | `/api/v1/system/upgrade` | Retained Dev update and restart. Body: `"dev"` | +| GET | `/api/v1/system/update/status` | Get Release check, download, or install state | +| POST | `/api/v1/system/update/check` | Check the latest stable v3 GitHub Release | +| POST | `/api/v1/system/update/download` | Start verified Release packages downloading in the background | +| POST | `/api/v1/system/update/install` | Confirm restart and install the prepared Release packages | | GET | `/api/v1/system/runscheduler` | Run scheduled service. Params: `jobid` (required) | | GET | `/api/v1/system/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` | | GET | `/api/v1/system/modulelist` | List loaded modules | diff --git a/skills/moviepilot-update/SKILL.md b/skills/moviepilot-update/SKILL.md index d7d6ac475..ea07a2027 100644 --- a/skills/moviepilot-update/SKILL.md +++ b/skills/moviepilot-update/SKILL.md @@ -1,7 +1,7 @@ --- name: moviepilot-update -version: 3 -description: Use this skill when you need to check MoviePilot versions, restart MoviePilot, or trigger a MoviePilot upgrade. Prefer the built-in system APIs instead of docker commands or manual file replacement. If auto-update on restart is already enabled, just restart. If it is disabled, call the upgrade API so MoviePilot performs a one-shot upgrade and restart. +version: 4 +description: Use this skill to check MoviePilot versions, inspect Release update state, download a Release update in the background, confirm installation, restart MoviePilot, or retain the existing Dev branch update flow. Prefer the built-in system APIs instead of container commands or manual file replacement. --- # MoviePilot Update @@ -32,37 +32,50 @@ python scripts/mp-update.py restart This calls `GET /api/v1/system/restart`. -### Upgrade and restart MoviePilot +### Release update -Release mode: +Check for a stable Release and inspect current progress: ```bash -python scripts/mp-update.py upgrade +python scripts/mp-update.py check +python scripts/mp-update.py status ``` -Dev mode: +Start the background download. This does not restart MoviePilot: + +```bash +python scripts/mp-update.py download +``` + +After `status` reports `state=ready`, installation requires a separate explicit confirmation: + +```bash +python scripts/mp-update.py install +``` + +`install` writes the verified install intent and restarts MoviePilot. Do not call it until the user explicitly confirms the restart. + +### Dev update and restart ```bash python scripts/mp-update.py upgrade dev ``` -This calls `POST /api/v1/system/upgrade`. - -Behavior: - -- If `MOVIEPILOT_AUTO_UPDATE` is already enabled (`release` or `dev`), MoviePilot only triggers a restart and lets the normal startup flow perform the upgrade. -- If `MOVIEPILOT_AUTO_UPDATE` is disabled, MoviePilot writes a one-shot upgrade flag, restarts itself, performs that single upgrade during startup, and then continues running without changing the persisted auto-update setting. +Dev mode retains the existing `POST /api/v1/system/upgrade` path with body `"dev"`. It tracks the current v3 development branch during restart. Release mode is no longer accepted by that endpoint. ## Direct API Examples ```bash python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/restart -python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"release"' +python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/check +python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/update/status +python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/download +python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/install python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"dev"' ``` ## Notes - These operations require administrator authentication. -- Restart or upgrade will interrupt the current agent session. Do not rely on post-restart follow-up steps in the same run. +- Only restart, Release installation, and Dev upgrade interrupt the current agent session. Checking and downloading remain online. - Prefer the API flow above. Only fall back to manual container commands when the API is unavailable. diff --git a/skills/moviepilot-update/scripts/mp-update.py b/skills/moviepilot-update/scripts/mp-update.py index 2bd5385ab..db3d608c6 100644 --- a/skills/moviepilot-update/scripts/mp-update.py +++ b/skills/moviepilot-update/scripts/mp-update.py @@ -23,8 +23,12 @@ def print_usage() -> None: print( "Usage:\n" f" python {Path(sys.argv[0]).name} versions\n" + f" python {Path(sys.argv[0]).name} status\n" + f" python {Path(sys.argv[0]).name} check\n" + f" python {Path(sys.argv[0]).name} download\n" + f" python {Path(sys.argv[0]).name} install\n" f" python {Path(sys.argv[0]).name} restart\n" - f" python {Path(sys.argv[0]).name} upgrade [release|dev]" + f" python {Path(sys.argv[0]).name} upgrade dev" ) @@ -42,12 +46,20 @@ def main() -> int: if command == "restart": return run_api_call(["GET", "/api/v1/system/restart"]) + update_commands = { + "status": ("GET", "/api/v1/system/update/status"), + "check": ("POST", "/api/v1/system/update/check"), + "download": ("POST", "/api/v1/system/update/download"), + "install": ("POST", "/api/v1/system/update/install"), + } + if command in update_commands: + method, path = update_commands[command] + return run_api_call([method, path]) + if command == "upgrade": - mode = (argv[1] if len(argv) > 1 else "release").strip().lower() - if mode == "true": - mode = "release" - if mode not in {"release", "dev"}: - print("Error: mode must be release or dev", file=sys.stderr) + mode = (argv[1] if len(argv) > 1 else "").strip().lower() + if mode != "dev": + print("Error: only Dev uses upgrade; use check/download/install for Release", file=sys.stderr) return 1 return run_api_call([ "POST", diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 9204ee08d..1144ddcd3 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6579, - "edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3", + "edge_count": 6595, + "edge_sha256": "b0709f6e54bf046386d5df81c54a3e65889f18473b8cc91bbe7c97010bda02dc", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -171,6 +171,19 @@ "app.adapters.system.rust -> app.runtime", "app.adapters.system.rust -> app.runtime.log", "app.adapters.system.rust -> app.runtime.settings", + "app.adapters.system.update -> app.adapters", + "app.adapters.system.update -> app.adapters.network", + "app.adapters.system.update -> app.adapters.network.http", + "app.adapters.system.update -> app.foundation", + "app.adapters.system.update -> app.foundation.environment", + "app.adapters.system.update -> app.foundation.singleton", + "app.adapters.system.update -> app.foundation.version", + "app.adapters.system.update -> app.runtime", + "app.adapters.system.update -> app.runtime.log", + "app.adapters.system.update -> app.runtime.settings", + "app.adapters.system.update -> app.runtime.thread", + "app.adapters.system.update -> app.schemas", + "app.adapters.system.update -> app.schemas.system", "app.adapters.web.correlation -> app.runtime", "app.adapters.web.correlation -> app.runtime.correlation", "app.adapters.web.health -> app.runtime", @@ -2263,6 +2276,7 @@ "app.api.endpoints.system -> app.adapters.network.http", "app.api.endpoints.system -> app.adapters.system", "app.api.endpoints.system -> app.adapters.system.rust", + "app.api.endpoints.system -> app.adapters.system.update", "app.api.endpoints.system -> app.adapters.web", "app.api.endpoints.system -> app.adapters.web.security", "app.api.endpoints.system -> app.adapters.web.security.access", @@ -5830,6 +5844,8 @@ "app.scheduler -> app.adapters", "app.scheduler -> app.adapters.external", "app.scheduler -> app.adapters.external.server", + "app.scheduler -> app.adapters.system", + "app.scheduler -> app.adapters.system.update", "app.scheduler -> app.application", "app.scheduler -> app.application.agent", "app.scheduler -> app.application.agentdata", @@ -6596,7 +6612,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 812, + "module_count": 813, "modules": [ "app", "app.adapters", @@ -6637,6 +6653,7 @@ "app.adapters.system.resource", "app.adapters.system.rust", "app.adapters.system.stdio", + "app.adapters.system.update", "app.adapters.web", "app.adapters.web.correlation", "app.adapters.web.health", diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py index df098be22..b24b6d6fc 100644 --- a/tests/test_cli_auto_update.py +++ b/tests/test_cli_auto_update.py @@ -1,9 +1,10 @@ from __future__ import annotations import importlib.util +import hashlib +import json import sys import tempfile -import unittest import uuid from pathlib import Path from types import ModuleType, SimpleNamespace @@ -15,12 +16,8 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py" class _DummySystemHelper: @staticmethod - def consume_one_shot_update_mode(): - return None - - @staticmethod - def get_auto_update_mode(): - return "false" + def consume_one_shot_dev_update(): + return False def load_cli_module(): @@ -39,6 +36,7 @@ def load_cli_module(): PROXY_HOST="", PIP_PROXY="", GITHUB_TOKEN="", + MOVIEPILOT_AUTO_UPDATE="false", PROXY={}, REPO_GITHUB_HEADERS=lambda _repo: {}, ) @@ -84,74 +82,123 @@ def load_cli_module(): return module -class CliAutoUpdateTests(unittest.TestCase): - def test_resolve_auto_update_targets_only_queries_backend_release(self): - module = load_cli_module() +def test_resolve_auto_update_targets_keeps_dev_branch_tracking(): + module = load_cli_module() + with patch.object(module, "_git_current_branch", return_value="v3"): + assert module._resolve_auto_update_targets("dev") == "latest" + assert module._resolve_auto_update_targets("release") is None - with patch.object(module, "_latest_release_tag", return_value="v2.10.12") as latest_mock: - backend_ref = module._resolve_auto_update_targets("release") - latest_mock.assert_called_once_with( - module.BACKEND_RELEASES_API, - repo="jxxghp/MoviePilot", - prefix="v2", - ) - self.assertEqual(backend_ref, "v2.10.12") +def test_one_shot_dev_update_overrides_disabled_default(): + module = load_cli_module() + module.settings.MOVIEPILOT_AUTO_UPDATE = "false" - def test_best_effort_auto_update_does_not_pass_frontend_version_override(self): - module = load_cli_module() - run_result = SimpleNamespace(returncode=0, stdout="ok") + with patch.object( + module.SystemHelper, "consume_one_shot_dev_update", return_value=True + ): + assert module._auto_update_mode() == "dev" - with patch.object(module, "_auto_update_mode", return_value="release"), patch.object( - module, "_resolve_auto_update_targets", return_value="v2.10.12" - ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( - module.click, "echo" - ): - module._best_effort_auto_update() - command = run_mock.call_args.args[0] - self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"]) - self.assertNotIn("--frontend-version", command) +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( + module.subprocess, "run" + ) as run_mock: + module._best_effort_auto_update() + run_mock.assert_not_called() - def test_best_effort_auto_update_passes_package_env_and_overrides_proxy(self): - module = load_cli_module() - module.settings.PROXY_HOST = "http://proxy.example:7890" - module.settings.PIP_PROXY = "https://mirror.example/simple" - run_result = SimpleNamespace(returncode=0, stdout="ok") - with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object( - module, "_auto_update_mode", return_value="release" - ), patch.object(module, "_resolve_auto_update_targets", return_value="v2.10.12"), patch.object( - module.subprocess, "run", return_value=run_result - ) as run_mock, patch.object( - module.click, "echo" - ): - module._best_effort_auto_update() - - env = run_mock.call_args.kwargs["env"] - self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890") - self.assertEqual(env["PIP_PROXY"], "https://mirror.example/simple") - self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(module.settings.PACKAGE_CACHE_PATH)) - self.assertEqual(env["UV_CACHE_DIR"], str(module.settings.PACKAGE_CACHE_PATH / "uv")) - - def test_best_effort_auto_update_derives_tool_cache_from_existing_root(self): - module = load_cli_module() - run_result = SimpleNamespace(returncode=0, stdout="ok") - package_cache_root = Path("/custom/package-cache-root") - - with patch.dict( - module.os.environ, +def test_prepared_release_uses_downloaded_package_before_dev_mode(): + module = load_cli_module() + module.PREPARED_UPDATE_ROOT.mkdir(parents=True) + backend = module.PREPARED_UPDATE_ROOT / "backend.zip" + frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip" + backend.write_bytes(b"backend") + frontend.write_bytes(b"frontend") + module.PREPARED_UPDATE_MANIFEST.write_text( + json.dumps( { - "PACKAGE_CACHE_ROOT": str(package_cache_root), - }, - clear=True, - ), patch.object(module, "_auto_update_mode", return_value="release"), patch.object( - module, "_resolve_auto_update_targets", return_value="v2.10.12" - ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( - module.click, "echo" - ): - module._best_effort_auto_update() + "version": "v3.1.0", + "frontend_version": "v3.1.0", + "backend_archive": str(backend), + "frontend_archive": str(frontend), + "backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(), + "frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(), + } + ), + encoding="utf-8", + ) + run_result = SimpleNamespace(returncode=0, stdout="ok") - env = run_mock.call_args.kwargs["env"] - self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(package_cache_root)) - self.assertEqual(env["UV_CACHE_DIR"], str(package_cache_root / "uv")) + with patch.object(module, "_auto_update_mode", return_value="dev") as mode, patch.object( + module.subprocess, "run", return_value=run_result + ) as run_mock, patch.object(module.click, "echo"): + module._best_effort_auto_update() + + command = run_mock.call_args.args[0] + assert "--offline-backend" in command + assert command[command.index("--frontend-archive") + 1] == str(frontend) + assert not module.PREPARED_UPDATE_MANIFEST.exists() + mode.assert_not_called() + + +def test_best_effort_auto_update_does_not_pass_frontend_version_override(): + module = load_cli_module() + run_result = SimpleNamespace(returncode=0, stdout="ok") + + with patch.object(module, "_auto_update_mode", return_value="dev"), patch.object( + module, "_resolve_auto_update_targets", return_value="latest" + ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( + module.click, "echo" + ): + module._best_effort_auto_update() + + command = run_mock.call_args.args[0] + assert command[1:5] == [ + str(module._repo_root() / "scripts" / "local_setup.py"), + "update", + "all", + "--ref", + ] + assert "--frontend-version" not in command + + +def test_best_effort_auto_update_passes_package_env_and_overrides_proxy(): + module = load_cli_module() + module.settings.PROXY_HOST = "http://proxy.example:7890" + module.settings.PIP_PROXY = "https://mirror.example/simple" + run_result = SimpleNamespace(returncode=0, stdout="ok") + + with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object( + module, "_auto_update_mode", return_value="dev" + ), patch.object(module, "_resolve_auto_update_targets", return_value="latest"), patch.object( + module.subprocess, "run", return_value=run_result + ) as run_mock, patch.object(module.click, "echo"): + module._best_effort_auto_update() + + env = run_mock.call_args.kwargs["env"] + assert env["HTTPS_PROXY"] == "http://proxy.example:7890" + assert env["PIP_PROXY"] == "https://mirror.example/simple" + assert env["PACKAGE_CACHE_ROOT"] == str(module.settings.PACKAGE_CACHE_PATH) + assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv") + + +def test_best_effort_auto_update_derives_tool_cache_from_existing_root(): + module = load_cli_module() + run_result = SimpleNamespace(returncode=0, stdout="ok") + package_cache_root = Path("/custom/package-cache-root") + + with patch.dict( + module.os.environ, + {"PACKAGE_CACHE_ROOT": str(package_cache_root)}, + clear=True, + ), patch.object(module, "_auto_update_mode", return_value="dev"), patch.object( + module, "_resolve_auto_update_targets", return_value="latest" + ), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object( + module.click, "echo" + ): + module._best_effort_auto_update() + + env = run_mock.call_args.kwargs["env"] + assert env["PACKAGE_CACHE_ROOT"] == str(package_cache_root) + assert env["UV_CACHE_DIR"] == str(package_cache_root / "uv") diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 284cd98c6..dba10dbf0 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -1,3 +1,5 @@ +import hashlib +import json import os import shlex import subprocess @@ -761,7 +763,64 @@ def test_updater_exposes_explicit_result( assert result.stdout == f"{expected}\n" -def test_release_noop_preserves_prerelease_selection_without_probing_package_index( +def test_prepared_release_is_verified_and_installed_without_release_lookup(tmp_path: Path) -> None: + config_dir = tmp_path / "config" + update_root = config_dir / "temp" / "moviepilot-update" + update_root.mkdir(parents=True) + backend = update_root / "backend.zip" + frontend = update_root / "frontend.zip" + backend.write_bytes(b"backend-package") + frontend.write_bytes(b"frontend-package") + backend_sha256 = hashlib.sha256(backend.read_bytes()).hexdigest() + frontend_sha256 = hashlib.sha256(frontend.read_bytes()).hexdigest() + (update_root / "install.json").write_text( + json.dumps( + { + "version": "v3.1.0", + "frontend_version": "v3.1.0", + "backend_archive": str(backend), + "frontend_archive": str(frontend), + "backend_sha256": backend_sha256, + "frontend_sha256": frontend_sha256, + } + ), + encoding="utf-8", + ) + release_probe = tmp_path / "release-probe" + script = textwrap.dedent( + f"""\ + CONFIG_DIR="$1" + MOVIEPILOT_AUTO_UPDATE=release + PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN= + RELEASE_PROBE="$2" + source {UPDATER!s} + INFO() {{ :; }} + WARN() {{ :; }} + ERROR() {{ :; }} + test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }} + install_backend_and_download_resources() {{ + test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true + test "$1" = tags/v3.1.0.zip + MOVIEPILOT_UPDATE_RESULT=updated + }} + run_moviepilot_update + printf '%s\n' "${{MOVIEPILOT_UPDATE_RESULT}}" + """ + ) + + result = subprocess.run( + ["bash", "-c", script, "prepared-update-test", str(config_dir), str(release_probe)], + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout == "updated\n" + assert not release_probe.exists() + assert not (update_root / "install.json").exists() + + +def test_release_mode_no_longer_checks_or_installs_during_restart( tmp_path: Path, ) -> None: package_probe = tmp_path / "package-probe" @@ -823,14 +882,8 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind assert result.stdout == "noop\n" assert not package_probe.exists() - curl_args = curl_log.read_text(encoding="utf-8") - assert "/releases" in curl_args - assert "/releases/latest" not in curl_args - assert "--compressed" in curl_args - assert "--fail" in curl_args - assert "--connect-timeout 5" in curl_args - assert "--max-time 15" in curl_args - assert comparison_log.read_text(encoding="utf-8") == "v3.0.0|v3.1.0-rc\n" + assert not curl_log.exists() + assert not comparison_log.exists() @pytest.mark.parametrize( diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 68242ca7d..d5f02846e 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -916,7 +916,7 @@ def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch): asyncio.run(server.serve()) -@pytest.mark.parametrize("endpoint_name", ["restart_system", "upgrade_system"]) +@pytest.mark.parametrize("endpoint_name", ["restart_system", "install_system_update"]) @pytest.mark.parametrize( "initially_stopped", [False, True], @@ -927,7 +927,7 @@ def test_restart_endpoint_failure_preserves_stop_state( endpoint_name, initially_stopped, ): - """重启或升级失败不能发布或撤销停止请求""" + """重启或更新安装失败不能发布或撤销停止请求""" from app.api.endpoints import system stop_event = threading.Event() @@ -935,19 +935,45 @@ def test_restart_endpoint_failure_preserves_stop_state( stop_event.set() monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event) monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True)) + monkeypatch.setattr(system.SystemHelper, "restart", MagicMock(return_value=(False, "restart failed"))) monkeypatch.setattr( - system.SystemHelper, - "restart" if endpoint_name == "restart_system" else "upgrade", - MagicMock(return_value=(False, "restart failed")), + system.system_update_manager, + "request_install", + MagicMock(return_value=(True, "prepared")), ) + cancel_install = MagicMock() + monkeypatch.setattr(system.system_update_manager, "cancel_install", cancel_install) if endpoint_name == "restart_system": response = system.restart_system(None) else: - response = system.upgrade_system(None, None) + response = system.install_system_update(None) assert not response.success assert stop_event.is_set() is initially_stopped + if endpoint_name == "install_system_update": + cancel_install.assert_called_once_with("restart failed") + else: + cancel_install.assert_not_called() + + +def test_upgrade_endpoint_retains_dev_mode_only(monkeypatch): + """旧升级入口只保留 Dev,Release 必须迁移到后台下载流程。""" + from app.api.endpoints import system + + monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True)) + upgrade_dev = MagicMock(return_value=(True, "dev queued")) + monkeypatch.setattr(system.SystemHelper, "upgrade_dev", upgrade_dev) + + dev_response = system.upgrade_system("dev", None) + release_response = system.upgrade_system("release", None) + legacy_default_response = system.upgrade_system(None, None) + + assert dev_response.success + assert not release_response.success + assert not legacy_default_response.success + assert "update/check" in release_response.message + upgrade_dev.assert_called_once_with() def test_command_restart_failure_does_not_publish_stop_request(monkeypatch): diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py new file mode 100644 index 000000000..29d20f3a9 --- /dev/null +++ b/tests/test_system_update_manager.py @@ -0,0 +1,170 @@ +"""系统后台更新状态机测试。""" + +import json +import threading +import zipfile +from pathlib import Path +from types import SimpleNamespace + +from app.adapters.system import update as update_module + + +def _manager(monkeypatch, tmp_path: Path): + monkeypatch.setattr( + update_module, + "get_runtime_setting", + lambda key: tmp_path if key == "TEMP_PATH" else None, + ) + manager = object.__new__(update_module.SystemUpdateManager) + manager._lock = threading.RLock() + manager._download_active = False + return manager + + +def _response(payload, status_code=200): + return SimpleNamespace(status_code=status_code, json=lambda: payload) + + +def test_check_exposes_new_stable_release(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + releases = [ + {"tag_name": "v3.2.0-beta", "prerelease": True, "draft": False}, + { + "tag_name": "v3.1.0", + "name": "MoviePilot v3.1.0", + "body": "changes", + "published_at": "2026-08-24T00:00:00Z", + "prerelease": False, + "draft": False, + }, + ] + monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases))) + monkeypatch.setattr(update_module, "APP_VERSION", "v3.0.0") + + status = manager.check() + + assert status.state == "available" + assert status.version == "v3.1.0" + assert status.release_notes == "changes" + assert status.can_update is True + + +def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + manager, + "_request", + lambda: SimpleNamespace(get_res=lambda _url: _response({}, status_code=503)), + ) + + status = manager.check() + + assert status.state == "idle" + assert status.error + + +def test_interrupted_download_becomes_retryable_failure(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + manager._write_state(state="downloading", version="v3.1.0") + + status = manager.get_status() + + assert status.state == "failed" + assert status.can_update is True + assert "中断" in status.error + + +def test_download_prepares_matching_backend_and_frontend_archives(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + backend_fixture = tmp_path / "source-backend.zip" + frontend_fixture = tmp_path / "source-frontend.zip" + with zipfile.ZipFile(backend_fixture, "w") as archive: + archive.writestr( + "MoviePilot-v3.1.0/version.py", + "APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n", + ) + archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n") + archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n") + with zipfile.ZipFile(frontend_fixture, "w") as archive: + archive.writestr("dist/index.html", "ok") + archive.writestr("dist/version.txt", "v3.1.0\n") + + fixtures = iter((backend_fixture, frontend_fixture)) + + def download(_url, destination, downloaded_before, _total_hint): + source = next(fixtures) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(source.read_bytes()) + size = destination.stat().st_size + return downloaded_before + size, size + + monkeypatch.setattr(manager, "_download_file", download) + monkeypatch.setattr(update_module, "is_docker", lambda: True) + monkeypatch.setattr( + manager, + "_fetch_frontend_release", + lambda _version: { + "assets": [ + { + "name": "dist.zip", + "size": frontend_fixture.stat().st_size, + "browser_download_url": "https://example.invalid/dist.zip", + "digest": f"sha256:{manager._sha256(frontend_fixture)}", + } + ] + }, + ) + + manager._write_state(state="downloading", version="v3.1.0") + manager._download_update("v3.1.0") + + status = manager.get_status() + prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8")) + assert status.state == "ready" + assert status.progress == 100 + assert status.frontend_version == "v3.1.0" + assert prepared["backend_sha256"] == manager._sha256(manager._backend_archive) + assert prepared["frontend_sha256"] == manager._sha256(manager._frontend_archive) + + +def test_request_install_rejects_modified_prepared_package(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + manager._root.mkdir(parents=True) + manager._backend_archive.write_bytes(b"backend") + manager._frontend_archive.write_bytes(b"frontend") + (manager._root / "prepared.json").write_text( + json.dumps( + { + "version": "v3.1.0", + "frontend_version": "v3.1.0", + "backend_archive": str(manager._backend_archive), + "frontend_archive": str(manager._frontend_archive), + "backend_sha256": "invalid", + "frontend_sha256": manager._sha256(manager._frontend_archive), + } + ), + encoding="utf-8", + ) + manager._write_state(state="ready", version="v3.1.0", can_install=True) + + success, message = manager.request_install() + + assert success is False + assert "后端更新包校验失败" in message + assert not manager._install_file.exists() + assert manager.get_status().state == "failed" + + +def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path): + manager = _manager(monkeypatch, tmp_path) + manager._root.mkdir(parents=True) + manager._install_file.write_text("{}", encoding="utf-8") + manager._write_state(state="installing", version="v3.1.0") + + manager.cancel_install("restart failed") + + status = manager.get_status() + assert status.state == "ready" + assert status.can_install is True + assert status.error == "restart failed" + assert not manager._install_file.exists() diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 4e8093c45..c0b61931f 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -15,7 +15,7 @@ import psutil import pytest from app.runtime.state import SystemHelper -from app.runtime.config import ConfigModel, settings +from app.runtime.config import ConfigModel, Settings, settings from app.adapters.system.host import SystemUtils @@ -523,6 +523,26 @@ def test_btrfs_fsid_dedup_setting_is_opt_in(): assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True +def test_legacy_release_auto_update_mode_is_disabled(monkeypatch): + """历史 Release 启动更新值迁移为关闭,Dev 值继续保留。""" + updates = [] + monkeypatch.setattr( + Settings, + "update_env_config", + lambda field, original, converted: updates.append( + (field, original, converted) + ), + ) + + assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false" + assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "false" + assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev" + assert updates == [ + ("MOVIEPILOT_AUTO_UPDATE", "release", "false"), + ("MOVIEPILOT_AUTO_UPDATE", "true", "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)]