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 插件市场仓库同步结果。"""