mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +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 RuleTestData as _SchemaRuleTestData
|
||||||
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
|
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
|
||||||
from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData
|
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.system import TorrentInfo as _SchemaTorrentInfo
|
||||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||||
from app.api.response import ResponseAPIRouter
|
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.foundation.environment import is_free_threaded_runtime, is_gil_enabled
|
||||||
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
||||||
from app.adapters.system import rust as rust_accel
|
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.security.url import SecurityUtils
|
||||||
from app.application.network import NetworkTestService
|
from app.application.network import NetworkTestService
|
||||||
from app.foundation.url import UrlUtils
|
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)
|
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(
|
def upgrade_system(
|
||||||
mode: Annotated[str | None, Body()] = None,
|
mode: Annotated[str | None, Body()] = None,
|
||||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
_: 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():
|
if not SystemHelper.can_restart():
|
||||||
return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!")
|
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])
|
@router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None])
|
||||||
|
|||||||
+110
-64
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -10,7 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
|
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import ProxyHandler, Request, build_opener, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import psutil
|
import psutil
|
||||||
@@ -35,10 +36,8 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
|
|||||||
HEALTH_PATH = "/api/v1/system/global"
|
HEALTH_PATH = "/api/v1/system/global"
|
||||||
HEALTH_TOKEN = "moviepilot"
|
HEALTH_TOKEN = "moviepilot"
|
||||||
FRONTEND_HEALTH_PATH = "/version.txt"
|
FRONTEND_HEALTH_PATH = "/version.txt"
|
||||||
BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
|
|
||||||
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
||||||
MANAGED_ACTIVE_STATES = {"running", "starting"}
|
MANAGED_ACTIVE_STATES = {"running", "starting"}
|
||||||
AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"}
|
|
||||||
MASKED_FIELDS = {
|
MASKED_FIELDS = {
|
||||||
"API_TOKEN",
|
"API_TOKEN",
|
||||||
"DB_POSTGRESQL_PASSWORD",
|
"DB_POSTGRESQL_PASSWORD",
|
||||||
@@ -48,6 +47,9 @@ MASKED_FIELDS = {
|
|||||||
}
|
}
|
||||||
MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY")
|
MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY")
|
||||||
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
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:
|
def _repo_root() -> Path:
|
||||||
@@ -221,49 +223,6 @@ def _release_prefix(version: Optional[str]) -> str:
|
|||||||
return matched.group(1) if matched else "v2"
|
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]:
|
def _git_current_branch() -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
branch = subprocess.check_output(
|
branch = subprocess.check_output(
|
||||||
@@ -277,33 +236,120 @@ def _git_current_branch() -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _auto_update_mode() -> str:
|
def _auto_update_mode() -> str:
|
||||||
one_shot_mode = SystemHelper.consume_one_shot_update_mode()
|
if SystemHelper.consume_one_shot_dev_update():
|
||||||
if one_shot_mode:
|
return "dev"
|
||||||
return one_shot_mode
|
return str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower()
|
||||||
return SystemHelper.get_auto_update_mode()
|
|
||||||
|
|
||||||
|
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]:
|
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||||
|
if mode != "dev":
|
||||||
|
return None
|
||||||
backend_prefix = _release_prefix(APP_VERSION)
|
backend_prefix = _release_prefix(APP_VERSION)
|
||||||
|
current_branch = _git_current_branch()
|
||||||
if mode == "dev":
|
backend_ref = "latest"
|
||||||
current_branch = _git_current_branch()
|
if not current_branch or current_branch == "HEAD":
|
||||||
backend_ref = "latest"
|
# 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。
|
||||||
if not current_branch or current_branch == "HEAD":
|
backend_ref = backend_prefix
|
||||||
# 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。
|
|
||||||
backend_ref = backend_prefix
|
|
||||||
else:
|
|
||||||
backend_ref = _latest_release_tag(
|
|
||||||
BACKEND_RELEASES_API,
|
|
||||||
repo="jxxghp/MoviePilot",
|
|
||||||
prefix=backend_prefix,
|
|
||||||
)
|
|
||||||
return backend_ref
|
return backend_ref
|
||||||
|
|
||||||
|
|
||||||
def _best_effort_auto_update() -> None:
|
def _best_effort_auto_update() -> None:
|
||||||
|
if _apply_prepared_release_update():
|
||||||
|
return
|
||||||
|
|
||||||
mode = _auto_update_mode()
|
mode = _auto_update_mode()
|
||||||
if mode not in AUTO_UPDATE_ENABLED_VALUES:
|
# Release 更新先在后台下载并经用户确认;这里只保留开发版分支跟踪。
|
||||||
|
if mode != "dev":
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+18
-2
@@ -317,8 +317,8 @@ class ConfigModel(BaseModel):
|
|||||||
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
||||||
|
|
||||||
# ==================== 系统升级配置 ====================
|
# ==================== 系统升级配置 ====================
|
||||||
# 重启自动升级
|
# 开发版仍可在启动时跟踪 v3 分支;Release 更新由后台更新服务管理。
|
||||||
MOVIEPILOT_AUTO_UPDATE: str = "release"
|
MOVIEPILOT_AUTO_UPDATE: str = "false"
|
||||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||||
AUTO_UPDATE_RESOURCE: bool = True
|
AUTO_UPDATE_RESOURCE: bool = True
|
||||||
|
|
||||||
@@ -899,6 +899,22 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return data
|
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 特殊验证
|
# 处理 API_TOKEN 特殊验证
|
||||||
if "API_TOKEN" in data:
|
if "API_TOKEN" in data:
|
||||||
converted_value, needs_update = cls.validate_api_token(
|
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 = {
|
CONFIG_WATCH = {
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
@@ -36,7 +35,7 @@ class SystemHelper(ConfigReloadMixin):
|
|||||||
__system_flag_file = "/var/log/nginx/__moviepilot__"
|
__system_flag_file = "/var/log/nginx/__moviepilot__"
|
||||||
__local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
|
__local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||||
__local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log"
|
__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"
|
__docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart"
|
||||||
__graceful_shutdown_monitor_lock = threading.Lock()
|
__graceful_shutdown_monitor_lock = threading.Lock()
|
||||||
__graceful_shutdown_monitor: Optional[threading.Thread] = None
|
__graceful_shutdown_monitor: Optional[threading.Thread] = None
|
||||||
@@ -97,94 +96,41 @@ class SystemHelper(ConfigReloadMixin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_auto_update_mode(mode: Optional[str]) -> str:
|
def queue_one_shot_dev_update() -> Tuple[bool, str]:
|
||||||
"""
|
"""写入一次性 Dev 更新标记,供本次重启的启动流程消费。"""
|
||||||
统一自动升级模式值,兼容历史 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"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
SystemHelper.__one_shot_update_flag_file.parent.mkdir(
|
SystemHelper.__one_shot_dev_update_flag_file.parent.mkdir(
|
||||||
parents=True, exist_ok=True
|
parents=True, exist_ok=True
|
||||||
)
|
)
|
||||||
SystemHelper.__one_shot_update_flag_file.write_text(
|
SystemHelper.__one_shot_dev_update_flag_file.write_text(
|
||||||
effective_mode, encoding="utf-8"
|
"dev", encoding="utf-8"
|
||||||
)
|
)
|
||||||
logger.info(f"已写入一次性升级标记,模式: {effective_mode}")
|
|
||||||
return True, ""
|
return True, ""
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
logger.error(f"写入一次性升级标记失败: {err}")
|
logger.error(f"写入一次性 Dev 更新标记失败: {err}")
|
||||||
return False, f"写入一次性升级标记失败:{err}"
|
return False, f"写入一次性 Dev 更新标记失败:{err}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def consume_one_shot_update_mode() -> Optional[str]:
|
def consume_one_shot_dev_update() -> bool:
|
||||||
"""
|
"""读取并删除一次性 Dev 更新标记,确保普通重启不会重复更新。"""
|
||||||
读取并清除一次性升级标记,避免后续启动重复执行。
|
path = SystemHelper.__one_shot_dev_update_flag_file
|
||||||
"""
|
|
||||||
path = SystemHelper.__one_shot_update_flag_file
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return False
|
||||||
|
|
||||||
try:
|
|
||||||
raw_mode = path.read_text(encoding="utf-8", errors="replace")
|
|
||||||
except OSError as err:
|
|
||||||
logger.warning(f"读取一次性升级标记失败: {err}")
|
|
||||||
raw_mode = ""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
mode = path.read_text(encoding="utf-8", errors="replace").strip().lower()
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
logger.warning(f"删除一次性升级标记失败: {err}")
|
logger.warning(f"消费一次性 Dev 更新标记失败: {err}")
|
||||||
|
return False
|
||||||
effective_mode = SystemHelper.normalize_auto_update_mode(raw_mode)
|
return mode == "dev"
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_one_shot_update_flag() -> None:
|
def clear_one_shot_dev_update() -> None:
|
||||||
"""
|
"""重启失败时撤销尚未消费的一次性 Dev 更新。"""
|
||||||
删除一次性升级标记。
|
|
||||||
"""
|
|
||||||
try:
|
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:
|
except OSError as err:
|
||||||
logger.warning(f"删除一次性升级标记失败: {err}")
|
logger.warning(f"清理一次性 Dev 更新标记失败: {err}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _spawn_local_restart_helper() -> None:
|
def _spawn_local_restart_helper() -> None:
|
||||||
@@ -332,32 +278,18 @@ class SystemHelper(ConfigReloadMixin):
|
|||||||
return SystemHelper._docker_api_restart()
|
return SystemHelper._docker_api_restart()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def upgrade(mode: str = "release") -> Tuple[bool, str]:
|
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
|
||||||
current_mode = SystemHelper.get_auto_update_mode()
|
ret, message = SystemHelper.restart()
|
||||||
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()
|
|
||||||
if not ret:
|
if not ret:
|
||||||
SystemHelper.clear_one_shot_update_flag()
|
SystemHelper.clear_one_shot_dev_update()
|
||||||
return ret, msg
|
return False, message
|
||||||
effective_mode = SystemHelper.normalize_auto_update_mode(mode)
|
return True, "已安排 Dev 更新并重启"
|
||||||
return True, f"已安排一次性 {effective_mode} 升级并重启"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _start_graceful_shutdown_monitor():
|
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.application.messaging.message import MessageHelper
|
||||||
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
||||||
from app.adapters.external.server import MoviePilotServerHelper
|
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.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.message import Message
|
from app.schemas.message import Message
|
||||||
@@ -556,6 +557,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
|
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
|
||||||
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
|
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
|
||||||
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
|
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
|
||||||
|
JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system"),
|
||||||
]).runtime_states()
|
]).runtime_states()
|
||||||
for job_id, job in self._jobs.items():
|
for job_id, job in self._jobs.items():
|
||||||
self._assign_job_generation(job_id, job)
|
self._assign_job_generation(job_id, job)
|
||||||
@@ -777,6 +779,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
kwargs={"job_id": "plugin_market_refresh"},
|
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._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ SCHEMA_EXPORTS = {
|
|||||||
'SystemErrorEventData': ('app.schemas.event', 'SystemErrorEventData'),
|
'SystemErrorEventData': ('app.schemas.event', 'SystemErrorEventData'),
|
||||||
'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'),
|
'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'),
|
||||||
'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'),
|
'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'),
|
||||||
|
'SystemUpdateStatus': ('app.schemas.system', 'SystemUpdateStatus'),
|
||||||
'TMDbException': ('app.schemas.exception', 'TMDbException'),
|
'TMDbException': ('app.schemas.exception', 'TMDbException'),
|
||||||
'Tag': ('app.schemas.context', 'Tag'),
|
'Tag': ('app.schemas.context', 'Tag'),
|
||||||
'TimeData': ('app.schemas.common', 'TimeData'),
|
'TimeData': ('app.schemas.common', 'TimeData'),
|
||||||
@@ -448,7 +449,7 @@ SCHEMA_CONFLICTS = {
|
|||||||
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
|
'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'],
|
'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'],
|
'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'],
|
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
'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'],
|
'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 dataclasses import dataclass
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
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)
|
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):
|
class PluginMarketSyncData(BaseModel):
|
||||||
"""Wiki 插件市场仓库同步结果。"""
|
"""Wiki 插件市场仓库同步结果。"""
|
||||||
|
|
||||||
|
|||||||
+10
-19
@@ -111,7 +111,7 @@ function load_config_from_app_env() {
|
|||||||
["GITHUB_PROXY"]=""
|
["GITHUB_PROXY"]=""
|
||||||
["PROXY_HOST"]=""
|
["PROXY_HOST"]=""
|
||||||
["GITHUB_TOKEN"]=""
|
["GITHUB_TOKEN"]=""
|
||||||
["MOVIEPILOT_AUTO_UPDATE"]="release"
|
["MOVIEPILOT_AUTO_UPDATE"]="false"
|
||||||
["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true"
|
["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true"
|
||||||
["MOVIEPILOT_FORCE_CHOWN"]="false"
|
["MOVIEPILOT_FORCE_CHOWN"]="false"
|
||||||
["MOVIEPILOT_SAFE_MODE"]="false"
|
["MOVIEPILOT_SAFE_MODE"]="false"
|
||||||
@@ -544,23 +544,15 @@ function correct_file_permissions() {
|
|||||||
load_config_from_app_env
|
load_config_from_app_env
|
||||||
apply_package_cache_env
|
apply_package_cache_env
|
||||||
|
|
||||||
# 一次性升级标记仅影响本次启动,避免把临时升级模式带入运行中的 Python 进程
|
# Dev 手动更新仍沿用一次性标记;Release 安装只消费已下载并校验的清单。
|
||||||
ONE_SHOT_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_update"
|
ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update"
|
||||||
ONE_SHOT_UPDATE_APPLIED="false"
|
ONE_SHOT_DEV_UPDATE="false"
|
||||||
MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}"
|
MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}"
|
||||||
if [ -f "${ONE_SHOT_UPDATE_FLAG}" ]; then
|
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
|
||||||
ONE_SHOT_UPDATE_MODE="$(tr -d '\r\n' < "${ONE_SHOT_UPDATE_FLAG}" | tr '[:upper:]' '[:lower:]')"
|
rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}"
|
||||||
rm -f "${ONE_SHOT_UPDATE_FLAG}"
|
MOVIEPILOT_AUTO_UPDATE="dev"
|
||||||
if [ "${ONE_SHOT_UPDATE_MODE}" = "true" ]; then
|
ONE_SHOT_DEV_UPDATE="true"
|
||||||
ONE_SHOT_UPDATE_MODE="release"
|
INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 使用env配置渲染 nginx 配置
|
# 使用env配置渲染 nginx 配置
|
||||||
@@ -583,10 +575,9 @@ if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
|
|||||||
else
|
else
|
||||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
MOVIEPILOT_UPDATE_RESULT="noop"
|
||||||
fi
|
fi
|
||||||
if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then
|
if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then
|
||||||
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
|
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
|
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
|
||||||
ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。"
|
ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。"
|
||||||
diagnostic_keepalive 1
|
diagnostic_keepalive 1
|
||||||
|
|||||||
+89
-35
@@ -31,6 +31,26 @@ PUBLIC_DIR=/public
|
|||||||
UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__"
|
UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__"
|
||||||
UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__"
|
UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__"
|
||||||
UPDATE_PREVIOUS_PUBLIC="${PUBLIC_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() {
|
function apply_package_cache_env() {
|
||||||
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
|
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
|
||||||
@@ -352,11 +372,19 @@ function swap_staged_payload() {
|
|||||||
# 下载程序资源,$1: 后端版本路径
|
# 下载程序资源,$1: 后端版本路径
|
||||||
function install_backend_and_download_resources() {
|
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 "后端程序下载失败,继续使用旧的程序来启动..."
|
WARN "后端程序下载失败,继续使用旧的程序来启动..."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
INFO "后端程序下载成功"
|
INFO "后端程序包准备成功"
|
||||||
|
|
||||||
# 检查依赖清单,实际同步延后到所有运行载荷准备完成之后。
|
# 检查依赖清单,实际同步延后到所有运行载荷准备完成之后。
|
||||||
INFO "→ 检查依赖变化..."
|
INFO "→ 检查依赖变化..."
|
||||||
@@ -373,7 +401,10 @@ function install_backend_and_download_resources() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 如果是"heads/v3.zip",则查找v3开头的最新版本号
|
# 如果是"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 "→ 正在获取前端最新版本号..."
|
INFO "→ 正在获取前端最新版本号..."
|
||||||
# 获取所有发布的版本列表,并筛选出以v3开头的版本号
|
# 获取所有发布的版本列表,并筛选出以v3开头的版本号
|
||||||
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^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}"
|
INFO "前端版本号:${frontend_version}"
|
||||||
fi
|
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 "前端程序下载失败,继续使用旧的程序来启动..."
|
WARN "前端程序下载失败,继续使用旧的程序来启动..."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
INFO "前端程序下载成功"
|
INFO "前端程序包准备成功"
|
||||||
INFO "→ 正在准备插件和站点资源..."
|
INFO "→ 正在准备插件和站点资源..."
|
||||||
if ! stage_runtime_payload; then
|
if ! stage_runtime_payload; then
|
||||||
ERROR "更新载荷准备失败,当前程序未替换"
|
ERROR "更新载荷准备失败,当前程序未替换"
|
||||||
@@ -648,7 +684,22 @@ function get_priority() {
|
|||||||
|
|
||||||
function run_moviepilot_update() {
|
function run_moviepilot_update() {
|
||||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
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)
|
TMP_PATH=$(mktemp -d)
|
||||||
if [ ! -d "${TMP_PATH}" ]; then
|
if [ ! -d "${TMP_PATH}" ]; then
|
||||||
# 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况
|
# 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况
|
||||||
@@ -658,13 +709,38 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
|||||||
fi
|
fi
|
||||||
mkdir -p /tmp/mp_update_path
|
mkdir -p /tmp/mp_update_path
|
||||||
fi
|
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
|
retries=0
|
||||||
while true; do
|
while true; do
|
||||||
if test_connectivity_github ${retries}; then
|
if test_connectivity_github ${retries}; then
|
||||||
break
|
break
|
||||||
else
|
|
||||||
retries=$((retries + 1))
|
|
||||||
fi
|
fi
|
||||||
|
retries=$((retries + 1))
|
||||||
done
|
done
|
||||||
INFO "Github:${GITHUB_LOG}"
|
INFO "Github:${GITHUB_LOG}"
|
||||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||||
@@ -672,34 +748,12 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
|||||||
else
|
else
|
||||||
CURL_HEADERS=""
|
CURL_HEADERS=""
|
||||||
fi
|
fi
|
||||||
if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
|
INFO "Dev 更新模式"
|
||||||
INFO "Dev 更新模式"
|
if ! install_backend_and_download_resources "heads/v3.zip"; then
|
||||||
if ! install_backend_and_download_resources "heads/v3.zip"; then
|
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
if [ -d "${TMP_PATH}" ]; then
|
rm -rf "${TMP_PATH}"
|
||||||
rm -rf "${TMP_PATH}"
|
|
||||||
fi
|
|
||||||
elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
|
|
||||||
INFO "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release"
|
|
||||||
else
|
else
|
||||||
INFO "MOVIEPILOT_AUTO_UPDATE 变量设置错误"
|
INFO "没有待安装更新,按当前版本启动"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -380,7 +380,9 @@ moviepilot version
|
|||||||
|
|
||||||
- `start` 会先启动后端,再启动前端
|
- `start` 会先启动后端,再启动前端
|
||||||
- `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置
|
- `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置
|
||||||
- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动
|
- `MOVIEPILOT_AUTO_UPDATE` 默认关闭;仅 `dev` 保留启动前跟踪当前 v3 开发分支的行为,更新失败只告警,不阻断当前启动
|
||||||
|
- Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,启动阶段只安装已下载且通过 SHA-256 校验的包
|
||||||
|
- 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示
|
||||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||||
- 后端默认监听 `PORT`,默认值 `3001`
|
- 后端默认监听 `PORT`,默认值 `3001`
|
||||||
|
|||||||
@@ -130,6 +130,18 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
|||||||
|
|
||||||
交互式接口文档 `/docs` 读取 `/api/v1/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。
|
交互式接口文档 `/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:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。
|
媒体识别、搜索和手动整理统一使用 `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 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
|
## Local CLI — Startup on Boot
|
||||||
|
|||||||
+57
-9
@@ -921,8 +921,20 @@ def install_node_runtime(node_version: str) -> Path:
|
|||||||
return node_bin
|
return node_bin
|
||||||
|
|
||||||
|
|
||||||
def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]:
|
def install_frontend(
|
||||||
version_tag, download_url = _resolve_frontend_release(frontend_version)
|
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)
|
node_bin = install_node_runtime(node_version)
|
||||||
|
|
||||||
if _frontend_runtime_ready(version_tag):
|
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}")
|
print_step(f"前端发布包已是最新版本:{version_tag}")
|
||||||
return {"version": version_tag, "node": str(node_bin)}
|
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:
|
with TemporaryDirectory() as temp_dir:
|
||||||
temp_path = Path(temp_dir)
|
temp_path = Path(temp_dir)
|
||||||
archive_path = temp_path / "dist.zip"
|
archive_path = temp_path / "dist.zip"
|
||||||
extract_dir = temp_path / "extract"
|
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)
|
extract_archive(archive_path, extract_dir)
|
||||||
dist_dir = extract_dir / "dist"
|
dist_dir = extract_dir / "dist"
|
||||||
if not dist_dir.exists():
|
if not dist_dir.exists():
|
||||||
raise RuntimeError("前端发布包中未找到 dist 目录")
|
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)
|
_remove_path(PUBLIC_DIR)
|
||||||
shutil.move(str(dist_dir), str(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():
|
if not (ROOT / ".git").exists():
|
||||||
raise RuntimeError("当前目录不是 Git 仓库,无法更新后端代码。")
|
raise RuntimeError("当前目录不是 Git 仓库,无法更新后端代码。")
|
||||||
|
|
||||||
_ensure_git_clean()
|
_ensure_git_clean()
|
||||||
print_step("获取远端更新")
|
if fetch:
|
||||||
run(["git", "fetch", "--tags", "origin"], cwd=ROOT)
|
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")
|
current_branch = _git_output("rev-parse", "--abbrev-ref", "HEAD")
|
||||||
if ref == "latest":
|
if ref == "latest":
|
||||||
@@ -3637,10 +3669,15 @@ def _update_backend_ref(ref: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def update_backend(
|
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:
|
) -> Path:
|
||||||
ensure_services_stopped()
|
ensure_services_stopped()
|
||||||
resolved_ref = _update_backend_ref(ref=ref)
|
resolved_ref = _update_backend_ref(ref=ref, fetch=fetch)
|
||||||
venv_python = install_deps(
|
venv_python = install_deps(
|
||||||
python_bin=python_bin, venv_dir=venv_dir, recreate=recreate
|
python_bin=python_bin, venv_dir=venv_dir, recreate=recreate
|
||||||
)
|
)
|
||||||
@@ -3860,6 +3897,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
update_parser.add_argument(
|
update_parser.add_argument(
|
||||||
"--frontend-version", help="前端版本,默认使用 version.py 中的 FRONTEND_VERSION"
|
"--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(
|
update_parser.add_argument(
|
||||||
"--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本"
|
"--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本"
|
||||||
)
|
)
|
||||||
@@ -4066,11 +4112,13 @@ def main() -> int:
|
|||||||
python_bin=args.python,
|
python_bin=args.python,
|
||||||
venv_dir=Path(args.venv),
|
venv_dir=Path(args.venv),
|
||||||
recreate=args.recreate,
|
recreate=args.recreate,
|
||||||
|
fetch=not args.offline_backend,
|
||||||
)
|
)
|
||||||
if args.target in {"frontend", "all"}:
|
if args.target in {"frontend", "all"}:
|
||||||
frontend_result = install_frontend(
|
frontend_result = install_frontend(
|
||||||
frontend_version=args.frontend_version,
|
frontend_version=args.frontend_version,
|
||||||
node_version=args.node_version,
|
node_version=args.node_version,
|
||||||
|
archive=Path(args.frontend_archive) if args.frontend_archive else None,
|
||||||
)
|
)
|
||||||
print_step(f"前端更新完成,版本:{frontend_result['version']}")
|
print_step(f"前端更新完成,版本:{frontend_result['version']}")
|
||||||
if args.target == "all" and not args.skip_resources:
|
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 |
|
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
|
||||||
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
|
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
|
||||||
|
|
||||||
### System (24 endpoints)
|
### System (28 endpoints)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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` | Non-sensitive settings. Params: `token` (required) |
|
||||||
| GET | `/api/v1/system/global/user` | User-related settings |
|
| GET | `/api/v1/system/global/user` | User-related settings |
|
||||||
| GET | `/api/v1/system/restart` | Restart system |
|
| 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/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/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` |
|
||||||
| GET | `/api/v1/system/modulelist` | List loaded modules |
|
| GET | `/api/v1/system/modulelist` | List loaded modules |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: moviepilot-update
|
name: moviepilot-update
|
||||||
version: 3
|
version: 4
|
||||||
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.
|
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
|
# MoviePilot Update
|
||||||
@@ -32,37 +32,50 @@ python scripts/mp-update.py restart
|
|||||||
|
|
||||||
This calls `GET /api/v1/system/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
|
```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
|
```bash
|
||||||
python scripts/mp-update.py upgrade dev
|
python scripts/mp-update.py upgrade dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This calls `POST /api/v1/system/upgrade`.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Direct API Examples
|
## Direct API Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/restart
|
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"'
|
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"dev"'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- These operations require administrator authentication.
|
- 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.
|
- 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(
|
print(
|
||||||
"Usage:\n"
|
"Usage:\n"
|
||||||
f" python {Path(sys.argv[0]).name} versions\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} 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":
|
if command == "restart":
|
||||||
return run_api_call(["GET", "/api/v1/system/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":
|
if command == "upgrade":
|
||||||
mode = (argv[1] if len(argv) > 1 else "release").strip().lower()
|
mode = (argv[1] if len(argv) > 1 else "").strip().lower()
|
||||||
if mode == "true":
|
if mode != "dev":
|
||||||
mode = "release"
|
print("Error: only Dev uses upgrade; use check/download/install for Release", file=sys.stderr)
|
||||||
if mode not in {"release", "dev"}:
|
|
||||||
print("Error: mode must be release or dev", file=sys.stderr)
|
|
||||||
return 1
|
return 1
|
||||||
return run_api_call([
|
return run_api_call([
|
||||||
"POST",
|
"POST",
|
||||||
|
|||||||
+20
-3
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6579,
|
"edge_count": 6595,
|
||||||
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
|
"edge_sha256": "b0709f6e54bf046386d5df81c54a3e65889f18473b8cc91bbe7c97010bda02dc",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -171,6 +171,19 @@
|
|||||||
"app.adapters.system.rust -> app.runtime",
|
"app.adapters.system.rust -> app.runtime",
|
||||||
"app.adapters.system.rust -> app.runtime.log",
|
"app.adapters.system.rust -> app.runtime.log",
|
||||||
"app.adapters.system.rust -> app.runtime.settings",
|
"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",
|
||||||
"app.adapters.web.correlation -> app.runtime.correlation",
|
"app.adapters.web.correlation -> app.runtime.correlation",
|
||||||
"app.adapters.web.health -> app.runtime",
|
"app.adapters.web.health -> app.runtime",
|
||||||
@@ -2263,6 +2276,7 @@
|
|||||||
"app.api.endpoints.system -> app.adapters.network.http",
|
"app.api.endpoints.system -> app.adapters.network.http",
|
||||||
"app.api.endpoints.system -> app.adapters.system",
|
"app.api.endpoints.system -> app.adapters.system",
|
||||||
"app.api.endpoints.system -> app.adapters.system.rust",
|
"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",
|
||||||
"app.api.endpoints.system -> app.adapters.web.security",
|
"app.api.endpoints.system -> app.adapters.web.security",
|
||||||
"app.api.endpoints.system -> app.adapters.web.security.access",
|
"app.api.endpoints.system -> app.adapters.web.security.access",
|
||||||
@@ -5830,6 +5844,8 @@
|
|||||||
"app.scheduler -> app.adapters",
|
"app.scheduler -> app.adapters",
|
||||||
"app.scheduler -> app.adapters.external",
|
"app.scheduler -> app.adapters.external",
|
||||||
"app.scheduler -> app.adapters.external.server",
|
"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",
|
||||||
"app.scheduler -> app.application.agent",
|
"app.scheduler -> app.application.agent",
|
||||||
"app.scheduler -> app.application.agentdata",
|
"app.scheduler -> app.application.agentdata",
|
||||||
@@ -6596,7 +6612,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 812,
|
"module_count": 813,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -6637,6 +6653,7 @@
|
|||||||
"app.adapters.system.resource",
|
"app.adapters.system.resource",
|
||||||
"app.adapters.system.rust",
|
"app.adapters.system.rust",
|
||||||
"app.adapters.system.stdio",
|
"app.adapters.system.stdio",
|
||||||
|
"app.adapters.system.update",
|
||||||
"app.adapters.web",
|
"app.adapters.web",
|
||||||
"app.adapters.web.correlation",
|
"app.adapters.web.correlation",
|
||||||
"app.adapters.web.health",
|
"app.adapters.web.health",
|
||||||
|
|||||||
+116
-69
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
@@ -15,12 +16,8 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
|
|||||||
|
|
||||||
class _DummySystemHelper:
|
class _DummySystemHelper:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def consume_one_shot_update_mode():
|
def consume_one_shot_dev_update():
|
||||||
return None
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_auto_update_mode():
|
|
||||||
return "false"
|
|
||||||
|
|
||||||
|
|
||||||
def load_cli_module():
|
def load_cli_module():
|
||||||
@@ -39,6 +36,7 @@ def load_cli_module():
|
|||||||
PROXY_HOST="",
|
PROXY_HOST="",
|
||||||
PIP_PROXY="",
|
PIP_PROXY="",
|
||||||
GITHUB_TOKEN="",
|
GITHUB_TOKEN="",
|
||||||
|
MOVIEPILOT_AUTO_UPDATE="false",
|
||||||
PROXY={},
|
PROXY={},
|
||||||
REPO_GITHUB_HEADERS=lambda _repo: {},
|
REPO_GITHUB_HEADERS=lambda _repo: {},
|
||||||
)
|
)
|
||||||
@@ -84,74 +82,123 @@ def load_cli_module():
|
|||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
class CliAutoUpdateTests(unittest.TestCase):
|
def test_resolve_auto_update_targets_keeps_dev_branch_tracking():
|
||||||
def test_resolve_auto_update_targets_only_queries_backend_release(self):
|
module = load_cli_module()
|
||||||
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(
|
def test_one_shot_dev_update_overrides_disabled_default():
|
||||||
module.BACKEND_RELEASES_API,
|
module = load_cli_module()
|
||||||
repo="jxxghp/MoviePilot",
|
module.settings.MOVIEPILOT_AUTO_UPDATE = "false"
|
||||||
prefix="v2",
|
|
||||||
)
|
|
||||||
self.assertEqual(backend_ref, "v2.10.12")
|
|
||||||
|
|
||||||
def test_best_effort_auto_update_does_not_pass_frontend_version_override(self):
|
with patch.object(
|
||||||
module = load_cli_module()
|
module.SystemHelper, "consume_one_shot_dev_update", return_value=True
|
||||||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
):
|
||||||
|
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]
|
def test_release_mode_does_not_update_during_start():
|
||||||
self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"])
|
module = load_cli_module()
|
||||||
self.assertNotIn("--frontend-version", command)
|
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(
|
def test_prepared_release_uses_downloaded_package_before_dev_mode():
|
||||||
module, "_auto_update_mode", return_value="release"
|
module = load_cli_module()
|
||||||
), patch.object(module, "_resolve_auto_update_targets", return_value="v2.10.12"), patch.object(
|
module.PREPARED_UPDATE_ROOT.mkdir(parents=True)
|
||||||
module.subprocess, "run", return_value=run_result
|
backend = module.PREPARED_UPDATE_ROOT / "backend.zip"
|
||||||
) as run_mock, patch.object(
|
frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip"
|
||||||
module.click, "echo"
|
backend.write_bytes(b"backend")
|
||||||
):
|
frontend.write_bytes(b"frontend")
|
||||||
module._best_effort_auto_update()
|
module.PREPARED_UPDATE_MANIFEST.write_text(
|
||||||
|
json.dumps(
|
||||||
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,
|
|
||||||
{
|
{
|
||||||
"PACKAGE_CACHE_ROOT": str(package_cache_root),
|
"version": "v3.1.0",
|
||||||
},
|
"frontend_version": "v3.1.0",
|
||||||
clear=True,
|
"backend_archive": str(backend),
|
||||||
), patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
|
"frontend_archive": str(frontend),
|
||||||
module, "_resolve_auto_update_targets", return_value="v2.10.12"
|
"backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(),
|
||||||
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
"frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(),
|
||||||
module.click, "echo"
|
}
|
||||||
):
|
),
|
||||||
module._best_effort_auto_update()
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||||||
|
|
||||||
env = run_mock.call_args.kwargs["env"]
|
with patch.object(module, "_auto_update_mode", return_value="dev") as mode, patch.object(
|
||||||
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(package_cache_root))
|
module.subprocess, "run", return_value=run_result
|
||||||
self.assertEqual(env["UV_CACHE_DIR"], str(package_cache_root / "uv"))
|
) 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 os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -761,7 +763,64 @@ def test_updater_exposes_explicit_result(
|
|||||||
assert result.stdout == f"{expected}\n"
|
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,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
package_probe = tmp_path / "package-probe"
|
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 result.stdout == "noop\n"
|
||||||
assert not package_probe.exists()
|
assert not package_probe.exists()
|
||||||
curl_args = curl_log.read_text(encoding="utf-8")
|
assert not curl_log.exists()
|
||||||
assert "/releases" in curl_args
|
assert not comparison_log.exists()
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -916,7 +916,7 @@ def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
|
|||||||
asyncio.run(server.serve())
|
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(
|
@pytest.mark.parametrize(
|
||||||
"initially_stopped",
|
"initially_stopped",
|
||||||
[False, True],
|
[False, True],
|
||||||
@@ -927,7 +927,7 @@ def test_restart_endpoint_failure_preserves_stop_state(
|
|||||||
endpoint_name,
|
endpoint_name,
|
||||||
initially_stopped,
|
initially_stopped,
|
||||||
):
|
):
|
||||||
"""重启或升级失败不能发布或撤销停止请求"""
|
"""重启或更新安装失败不能发布或撤销停止请求"""
|
||||||
from app.api.endpoints import system
|
from app.api.endpoints import system
|
||||||
|
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
@@ -935,19 +935,45 @@ def test_restart_endpoint_failure_preserves_stop_state(
|
|||||||
stop_event.set()
|
stop_event.set()
|
||||||
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
|
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
|
||||||
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
|
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
|
||||||
|
monkeypatch.setattr(system.SystemHelper, "restart", MagicMock(return_value=(False, "restart failed")))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
system.SystemHelper,
|
system.system_update_manager,
|
||||||
"restart" if endpoint_name == "restart_system" else "upgrade",
|
"request_install",
|
||||||
MagicMock(return_value=(False, "restart failed")),
|
MagicMock(return_value=(True, "prepared")),
|
||||||
)
|
)
|
||||||
|
cancel_install = MagicMock()
|
||||||
|
monkeypatch.setattr(system.system_update_manager, "cancel_install", cancel_install)
|
||||||
|
|
||||||
if endpoint_name == "restart_system":
|
if endpoint_name == "restart_system":
|
||||||
response = system.restart_system(None)
|
response = system.restart_system(None)
|
||||||
else:
|
else:
|
||||||
response = system.upgrade_system(None, None)
|
response = system.install_system_update(None)
|
||||||
|
|
||||||
assert not response.success
|
assert not response.success
|
||||||
assert stop_event.is_set() is initially_stopped
|
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):
|
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
|
import pytest
|
||||||
|
|
||||||
from app.runtime.state import SystemHelper
|
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
|
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
|
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():
|
def test_space_usage_default_path_does_not_read_fsid():
|
||||||
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
|
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
|
||||||
paths = [Path(tmp1), Path(tmp2)]
|
paths = [Path(tmp1), Path(tmp2)]
|
||||||
|
|||||||
Reference in New Issue
Block a user