feat(system): add staged release updates

This commit is contained in:
jxxghp
2026-08-24 17:58:03 +08:00
parent 5415398027
commit 84326e20de
23 changed files with 1393 additions and 350 deletions
+477
View File
@@ -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()
+70 -9
View File
@@ -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])
+110 -64
View File
@@ -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:
+18 -2
View File
@@ -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(
+31 -99
View File
@@ -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():
+13
View File
@@ -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,
+2 -1
View File
@@ -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'],
+27 -1
View File
@@ -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 插件市场仓库同步结果。"""
+10 -19
View File
@@ -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
+89 -35
View File
@@ -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
}
+3 -1
View File
@@ -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`
+12
View File
@@ -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:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。
+2
View File
@@ -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
+57 -9
View File
@@ -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:
+6 -2
View File
@@ -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 |
+27 -14
View File
@@ -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.
+18 -6
View File
@@ -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",
+20 -3
View File
@@ -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",
+116 -69
View File
@@ -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")
+62 -9
View File
@@ -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(
+32 -6
View File
@@ -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):
+170
View File
@@ -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()
+21 -1
View File
@@ -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)]