mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 20:54:32 +08:00
feat(system): add staged release updates
This commit is contained in:
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user