mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat(system): add staged release updates
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
"""MoviePilot Release 后台检查、下载与待安装状态管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.foundation.version import compare_version
|
||||
from app.foundation.environment import is_docker
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.runtime.thread import ThreadHelper
|
||||
from app.schemas.system import SystemUpdateStatus
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemUpdateManager(metaclass=SingletonClass):
|
||||
"""持久化更新状态,并保证同一时刻只有一个下载任务。"""
|
||||
|
||||
_BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
|
||||
_FRONTEND_RELEASE_API = (
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/tags/{tag}"
|
||||
)
|
||||
_BACKEND_ARCHIVE_URL = (
|
||||
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/{tag}.zip"
|
||||
)
|
||||
_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:[-.](?:alpha|beta|rc)\d*)?$", re.I)
|
||||
_STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+$", re.I)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._download_active = False
|
||||
|
||||
@property
|
||||
def _root(self) -> Path:
|
||||
return Path(get_runtime_setting("TEMP_PATH")) / "moviepilot-update"
|
||||
|
||||
@property
|
||||
def _state_file(self) -> Path:
|
||||
return self._root / "state.json"
|
||||
|
||||
@property
|
||||
def _install_file(self) -> Path:
|
||||
return self._root / "install.json"
|
||||
|
||||
@property
|
||||
def _backend_archive(self) -> Path:
|
||||
return self._root / "backend.zip"
|
||||
|
||||
@property
|
||||
def _frontend_archive(self) -> Path:
|
||||
return self._root / "frontend.zip"
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _default_state(self) -> dict[str, Any]:
|
||||
return SystemUpdateStatus(current_version=APP_VERSION).model_dump()
|
||||
|
||||
def _read_state(self) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
return {**self._default_state(), **payload, "current_version": APP_VERSION}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return self._default_state()
|
||||
|
||||
def _write_state(self, **changes: Any) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
state.update(changes)
|
||||
state["current_version"] = APP_VERSION
|
||||
state["progress"] = self._progress(
|
||||
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
|
||||
)
|
||||
validated = SystemUpdateStatus.model_validate(state).model_dump()
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self._state_file.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(validated, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
temporary.replace(self._state_file)
|
||||
return validated
|
||||
|
||||
@staticmethod
|
||||
def _progress(downloaded: Any, total: Any) -> int:
|
||||
try:
|
||||
downloaded_value = max(0, int(downloaded))
|
||||
total_value = max(0, int(total))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if total_value <= 0:
|
||||
return 0
|
||||
return min(100, int(downloaded_value * 100 / total_value))
|
||||
|
||||
def get_status(self) -> SystemUpdateStatus:
|
||||
"""返回状态快照,并在更新完成后的新进程中清理安装终态。"""
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
target = str(state.get("version") or "")
|
||||
if state.get("state") == "downloading" and not self._download_active:
|
||||
state = self._write_state(
|
||||
state="failed",
|
||||
error="更新包下载因服务重启而中断,请重试",
|
||||
can_update=True,
|
||||
can_install=False,
|
||||
)
|
||||
if state.get("state") == "installing" and target == APP_VERSION:
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
state = self._write_state(
|
||||
state="idle",
|
||||
version=None,
|
||||
frontend_version=None,
|
||||
release_name=None,
|
||||
release_notes=None,
|
||||
published_at=None,
|
||||
downloaded_bytes=0,
|
||||
total_bytes=0,
|
||||
error=None,
|
||||
can_update=False,
|
||||
can_install=False,
|
||||
)
|
||||
return SystemUpdateStatus.model_validate(state)
|
||||
|
||||
def check(self) -> SystemUpdateStatus:
|
||||
"""查询 GitHub 稳定版 v3 Release,并保留正在下载或待安装状态。"""
|
||||
current = self.get_status()
|
||||
if current.state in {"downloading", "ready", "installing"}:
|
||||
return current
|
||||
|
||||
try:
|
||||
response = self._request().get_res(self._BACKEND_RELEASES_API)
|
||||
if response is None or response.status_code != 200:
|
||||
raise RuntimeError("GitHub Release 请求失败")
|
||||
releases = response.json()
|
||||
release = next(
|
||||
(
|
||||
item
|
||||
for item in releases
|
||||
if isinstance(item, dict)
|
||||
and not item.get("draft")
|
||||
and not item.get("prerelease")
|
||||
and self._STABLE_VERSION_PATTERN.fullmatch(
|
||||
str(item.get("tag_name") or "")
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not release:
|
||||
raise RuntimeError("未找到可用的 v3 稳定版本")
|
||||
version = str(release["tag_name"])
|
||||
has_update = compare_version(version, "gt", APP_VERSION) is True
|
||||
return SystemUpdateStatus.model_validate(
|
||||
self._write_state(
|
||||
state="available" if has_update else "idle",
|
||||
version=version if has_update else None,
|
||||
frontend_version=None,
|
||||
release_name=str(release.get("name") or version) if has_update else None,
|
||||
release_notes=str(release.get("body") or "") if has_update else None,
|
||||
published_at=release.get("published_at") if has_update else None,
|
||||
checked_at=self._now(),
|
||||
downloaded_bytes=0,
|
||||
total_bytes=0,
|
||||
error=None,
|
||||
can_update=has_update,
|
||||
can_install=False,
|
||||
)
|
||||
)
|
||||
except Exception as error: # 定时检查失败不应打扰用户,下载失败才进入可见错误态
|
||||
logger.warning(f"检查 MoviePilot 更新失败: {error}")
|
||||
return SystemUpdateStatus.model_validate(
|
||||
self._write_state(
|
||||
state="idle",
|
||||
checked_at=self._now(),
|
||||
error=str(error),
|
||||
can_update=False,
|
||||
can_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self) -> SystemUpdateStatus:
|
||||
"""启动唯一后台下载线程,并立即返回下载中状态。"""
|
||||
with self._lock:
|
||||
state = self.get_status()
|
||||
if state.state == "ready":
|
||||
return state
|
||||
if state.state == "downloading" and self._download_active:
|
||||
return state
|
||||
if state.state != "available" or not state.version:
|
||||
state = self.check()
|
||||
if state.state != "available" or not state.version:
|
||||
return state
|
||||
|
||||
self._backend_archive.unlink(missing_ok=True)
|
||||
self._frontend_archive.unlink(missing_ok=True)
|
||||
self._write_state(
|
||||
state="downloading",
|
||||
downloaded_bytes=0,
|
||||
total_bytes=0,
|
||||
error=None,
|
||||
can_update=False,
|
||||
can_install=False,
|
||||
)
|
||||
self._download_active = True
|
||||
try:
|
||||
ThreadHelper().submit(self._download_update, state.version)
|
||||
except RuntimeError as error:
|
||||
self._download_active = False
|
||||
return SystemUpdateStatus.model_validate(
|
||||
self._write_state(
|
||||
state="failed",
|
||||
error=f"无法启动更新包下载:{error}",
|
||||
can_update=True,
|
||||
can_install=False,
|
||||
)
|
||||
)
|
||||
return self.get_status()
|
||||
|
||||
def request_install(self) -> tuple[bool, str]:
|
||||
"""校验待安装文件并写入启动阶段消费的安装意图。"""
|
||||
with self._lock:
|
||||
state = self.get_status()
|
||||
if state.state != "ready" or not state.version:
|
||||
return False, "更新包尚未下载完成"
|
||||
try:
|
||||
backend_sha256 = self._sha256(self._backend_archive)
|
||||
frontend_sha256 = self._sha256(self._frontend_archive)
|
||||
prepared = self._read_prepared_manifest()
|
||||
if backend_sha256 != prepared.get("backend_sha256"):
|
||||
raise RuntimeError("后端更新包校验失败")
|
||||
if frontend_sha256 != prepared.get("frontend_sha256"):
|
||||
raise RuntimeError("前端更新包校验失败")
|
||||
self._install_file.write_text(
|
||||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
self._write_state(state="installing", can_install=False, error=None)
|
||||
return True, "更新包已就绪,正在重启安装"
|
||||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||||
self._write_state(state="failed", error=str(error), can_install=False)
|
||||
return False, str(error)
|
||||
|
||||
def cancel_install(self, reason: str) -> None:
|
||||
"""重启请求失败时撤销安装意图,避免下次普通启动意外安装。"""
|
||||
with self._lock:
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
self._write_state(
|
||||
state="ready",
|
||||
error=reason,
|
||||
can_update=False,
|
||||
can_install=True,
|
||||
)
|
||||
|
||||
def _request(self) -> RequestUtils:
|
||||
return RequestUtils(
|
||||
proxies=get_runtime_setting("PROXY"),
|
||||
headers=get_runtime_setting("GITHUB_HEADERS"),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
def _download_update(self, version: str) -> None:
|
||||
try:
|
||||
downloaded = 0
|
||||
downloaded, backend_total = self._download_file(
|
||||
self._proxied(self._BACKEND_ARCHIVE_URL.format(tag=version)),
|
||||
self._backend_archive,
|
||||
downloaded,
|
||||
0,
|
||||
)
|
||||
frontend_version = self._validate_backend_archive(version)
|
||||
frontend_release = self._fetch_frontend_release(frontend_version)
|
||||
frontend_asset = next(
|
||||
(
|
||||
item
|
||||
for item in frontend_release.get("assets") or []
|
||||
if item.get("name") == "dist.zip" and item.get("browser_download_url")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not frontend_asset:
|
||||
raise RuntimeError(f"前端 {frontend_version} 缺少 dist.zip 发布资产")
|
||||
frontend_total = int(frontend_asset.get("size") or 0)
|
||||
total = backend_total + frontend_total
|
||||
self._write_state(
|
||||
frontend_version=frontend_version,
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total,
|
||||
)
|
||||
downloaded, _ = self._download_file(
|
||||
self._proxied(str(frontend_asset["browser_download_url"])),
|
||||
self._frontend_archive,
|
||||
downloaded,
|
||||
total,
|
||||
)
|
||||
self._validate_frontend_archive(frontend_version)
|
||||
expected_digest = str(frontend_asset.get("digest") or "")
|
||||
frontend_sha256 = self._sha256(self._frontend_archive)
|
||||
if expected_digest.startswith("sha256:") and frontend_sha256 != expected_digest.removeprefix("sha256:"):
|
||||
raise RuntimeError("前端更新包与 GitHub Release 摘要不一致")
|
||||
|
||||
if not is_docker():
|
||||
self._prepare_local_backend_ref(version)
|
||||
|
||||
prepared = {
|
||||
"version": version,
|
||||
"frontend_version": frontend_version,
|
||||
"backend_archive": str(self._backend_archive),
|
||||
"frontend_archive": str(self._frontend_archive),
|
||||
"backend_sha256": self._sha256(self._backend_archive),
|
||||
"frontend_sha256": frontend_sha256,
|
||||
"prepared_at": self._now(),
|
||||
}
|
||||
(self._root / "prepared.json").write_text(
|
||||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
self._write_state(
|
||||
state="ready",
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=max(total, downloaded),
|
||||
error=None,
|
||||
can_update=False,
|
||||
can_install=True,
|
||||
)
|
||||
logger.info(f"MoviePilot {version} 更新包已下载完成,等待用户确认重启")
|
||||
except Exception as error: # 后台线程必须把所有失败沉淀为可查询状态
|
||||
logger.error(f"下载 MoviePilot 更新包失败: {error}")
|
||||
self._write_state(
|
||||
state="failed", error=str(error), can_update=True, can_install=False
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._download_active = False
|
||||
|
||||
def _download_file(
|
||||
self, url: str, destination: Path, downloaded_before: int, total_hint: int
|
||||
) -> tuple[int, int]:
|
||||
temporary = destination.with_suffix(".part")
|
||||
temporary.unlink(missing_ok=True)
|
||||
with self._request().get_stream(url) as response:
|
||||
if response is None or response.status_code != 200:
|
||||
raise RuntimeError(f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}")
|
||||
content_length = int(response.headers.get("content-length") or 0)
|
||||
total = total_hint or content_length
|
||||
current = downloaded_before
|
||||
with temporary.open("wb") as output:
|
||||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
output.write(chunk)
|
||||
current += len(chunk)
|
||||
self._write_state(downloaded_bytes=current, total_bytes=total)
|
||||
temporary.replace(destination)
|
||||
return current, content_length
|
||||
|
||||
def _fetch_frontend_release(self, version: str) -> dict[str, Any]:
|
||||
response = self._request().get_res(
|
||||
self._FRONTEND_RELEASE_API.format(tag=version)
|
||||
)
|
||||
if response is None or response.status_code != 200:
|
||||
raise RuntimeError(f"无法获取前端 {version} Release")
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("前端 Release 返回格式异常")
|
||||
return payload
|
||||
|
||||
def _validate_backend_archive(self, version: str) -> str:
|
||||
with zipfile.ZipFile(self._backend_archive) as archive:
|
||||
self._validate_zip_members(archive)
|
||||
version_name = next(
|
||||
(name for name in archive.namelist() if name.count("/") == 1 and name.endswith("/version.py")),
|
||||
None,
|
||||
)
|
||||
if not version_name:
|
||||
raise RuntimeError("后端更新包缺少 version.py")
|
||||
version_source = archive.read(version_name).decode("utf-8")
|
||||
app_match = re.search(r"^APP_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
|
||||
frontend_match = re.search(r"^FRONTEND_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
|
||||
if not app_match or app_match.group(1) != version:
|
||||
raise RuntimeError("后端更新包版本与目标 Release 不一致")
|
||||
if not frontend_match or not self._VERSION_PATTERN.fullmatch(frontend_match.group(1)):
|
||||
raise RuntimeError("后端更新包声明的前端版本无效")
|
||||
required = ("pyproject.toml", "uv.lock")
|
||||
names = archive.namelist()
|
||||
if any(not any(name.endswith(f"/{item}") for name in names) for item in required):
|
||||
raise RuntimeError("后端更新包缺少依赖锁定文件")
|
||||
return frontend_match.group(1)
|
||||
|
||||
def _validate_frontend_archive(self, version: str) -> None:
|
||||
with zipfile.ZipFile(self._frontend_archive) as archive:
|
||||
self._validate_zip_members(archive)
|
||||
names = set(archive.namelist())
|
||||
if "dist/index.html" not in names or "dist/version.txt" not in names:
|
||||
raise RuntimeError("前端更新包结构无效")
|
||||
archived_version = archive.read("dist/version.txt").decode("utf-8").strip()
|
||||
if archived_version != version:
|
||||
raise RuntimeError("前端更新包版本与后端声明不一致")
|
||||
|
||||
@staticmethod
|
||||
def _validate_zip_members(archive: zipfile.ZipFile) -> None:
|
||||
for item in archive.infolist():
|
||||
path = PurePosixPath(item.filename)
|
||||
file_type = (item.external_attr >> 16) & 0o170000
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or file_type == stat.S_IFLNK
|
||||
):
|
||||
raise RuntimeError("更新包包含不安全路径")
|
||||
|
||||
def _read_prepared_manifest(self) -> dict[str, Any]:
|
||||
payload = json.loads((self._root / "prepared.json").read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("更新包清单格式无效")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _prepare_local_backend_ref(version: str) -> None:
|
||||
"""本地 CLI 在下载阶段获取标签,使重启后的代码切换不再联网。"""
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
if not (root / ".git").is_dir():
|
||||
raise RuntimeError("本地安装目录不是 Git 仓库,无法准备 Release 更新")
|
||||
try:
|
||||
worktree = subprocess.run(
|
||||
["git", "status", "--porcelain", "--untracked-files=no"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if worktree.stdout.strip():
|
||||
raise RuntimeError("本地源码存在未提交改动,无法准备 Release 更新")
|
||||
subprocess.run(
|
||||
["git", "fetch", "--no-tags", "origin", "tag", version],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"{version}^{{commit}}"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||
raise RuntimeError(f"无法准备本地 Release 标签 {version}") from error
|
||||
|
||||
@staticmethod
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file_handle:
|
||||
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _proxied(url: str) -> str:
|
||||
proxy = str(get_runtime_setting("GITHUB_PROXY") or "").strip()
|
||||
return f"{proxy}{url}" if proxy else url
|
||||
|
||||
|
||||
system_update_manager = SystemUpdateManager()
|
||||
@@ -28,6 +28,7 @@ from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyn
|
||||
from app.schemas.system import RuleTestData as _SchemaRuleTestData
|
||||
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
|
||||
from app.schemas.system import SystemModuleListData as _SchemaSystemModuleListData
|
||||
from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus
|
||||
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
from app.api.response import ResponseAPIRouter
|
||||
@@ -74,6 +75,7 @@ from app.foundation.crypto import HashUtils
|
||||
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
|
||||
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
||||
from app.adapters.system import rust as rust_accel
|
||||
from app.adapters.system.update import system_update_manager
|
||||
from app.application.security.url import SecurityUtils
|
||||
from app.application.network import NetworkTestService
|
||||
from app.foundation.url import UrlUtils
|
||||
@@ -1495,22 +1497,81 @@ def restart_system(_: ApiPrincipal = Depends(get_current_active_superuser)):
|
||||
return _SchemaResponse(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.post("/upgrade", summary="升级并重启系统", response_model=_SchemaResponse[None])
|
||||
@router.post("/upgrade", summary="Dev 更新并重启系统", response_model=_SchemaResponse[None])
|
||||
def upgrade_system(
|
||||
mode: Annotated[str | None, Body()] = None,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""
|
||||
触发系统升级并重启(仅管理员)
|
||||
|
||||
- 当前已开启自动升级时:直接重启,由启动流程完成升级。
|
||||
- 当前未开启自动升级时:写入一次性升级标记,本次重启后仅执行一次升级。
|
||||
"""
|
||||
"""保留 Dev 更新入口;Release 更新必须使用后台下载与确认安装流程。"""
|
||||
if str(mode or "").strip().lower() != "dev":
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message="Release 更新请使用 /system/update/check、download 和 install 接口",
|
||||
)
|
||||
if not SystemHelper.can_restart():
|
||||
return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!")
|
||||
success, message = SystemHelper.upgrade_dev()
|
||||
return _SchemaResponse(success=success, message=message)
|
||||
|
||||
ret, msg = SystemHelper.upgrade(mode=mode or "release")
|
||||
return _SchemaResponse(success=ret, message=msg)
|
||||
|
||||
@router.get(
|
||||
"/update/status",
|
||||
summary="查询系统更新状态",
|
||||
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
|
||||
)
|
||||
def system_update_status(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""返回后台检查、下载或待安装状态(仅管理员)。"""
|
||||
return _SchemaResponse(success=True, data=system_update_manager.get_status())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update/check",
|
||||
summary="立即检查系统更新",
|
||||
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
|
||||
)
|
||||
def check_system_update(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""立即查询 GitHub Release(仅管理员)。"""
|
||||
return _SchemaResponse(success=True, data=system_update_manager.check())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update/download",
|
||||
summary="后台下载系统更新",
|
||||
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
|
||||
)
|
||||
def download_system_update(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""启动后台下载并立即返回当前状态(仅管理员)。"""
|
||||
if not SystemHelper.can_restart():
|
||||
return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!")
|
||||
status = system_update_manager.start_download()
|
||||
return _SchemaResponse(success=status.state != "failed", data=status, message=status.error)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/update/install",
|
||||
summary="确认重启安装系统更新",
|
||||
response_model=_SchemaResponse[None],
|
||||
)
|
||||
def install_system_update(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""确认消费已校验更新包,并重启进入安装阶段(仅管理员)。"""
|
||||
if not SystemHelper.can_restart():
|
||||
return _SchemaResponse(success=False, message="当前运行环境不支持升级操作!")
|
||||
prepared, message = system_update_manager.request_install()
|
||||
if not prepared:
|
||||
return _SchemaResponse(success=False, message=message)
|
||||
ret, msg = SystemHelper.restart()
|
||||
if not ret:
|
||||
system_update_manager.cancel_install(msg)
|
||||
return _SchemaResponse(success=False, message=msg)
|
||||
return _SchemaResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None])
|
||||
|
||||
+110
-64
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -10,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import ProxyHandler, Request, build_opener, urlopen
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import click
|
||||
import psutil
|
||||
@@ -35,10 +36,8 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
|
||||
HEALTH_PATH = "/api/v1/system/global"
|
||||
HEALTH_TOKEN = "moviepilot"
|
||||
FRONTEND_HEALTH_PATH = "/version.txt"
|
||||
BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
|
||||
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
||||
MANAGED_ACTIVE_STATES = {"running", "starting"}
|
||||
AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"}
|
||||
MASKED_FIELDS = {
|
||||
"API_TOKEN",
|
||||
"DB_POSTGRESQL_PASSWORD",
|
||||
@@ -48,6 +47,9 @@ MASKED_FIELDS = {
|
||||
}
|
||||
MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY")
|
||||
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
||||
PREPARED_UPDATE_ROOT = settings.TEMP_PATH / "moviepilot-update"
|
||||
PREPARED_UPDATE_MANIFEST = PREPARED_UPDATE_ROOT / "install.json"
|
||||
PREPARED_UPDATE_STATE = PREPARED_UPDATE_ROOT / "state.json"
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
@@ -221,49 +223,6 @@ def _release_prefix(version: Optional[str]) -> str:
|
||||
return matched.group(1) if matched else "v2"
|
||||
|
||||
|
||||
def _release_sort_key(tag: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in re.findall(r"\d+", tag))
|
||||
|
||||
|
||||
def _github_api_json(url: str, *, repo: str) -> Any:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": settings.USER_AGENT,
|
||||
}
|
||||
headers.update(settings.REPO_GITHUB_HEADERS(repo))
|
||||
opener = build_opener(ProxyHandler(settings.PROXY or {}))
|
||||
request = Request(url=url, headers=headers, method="GET")
|
||||
|
||||
try:
|
||||
with opener.open(request, timeout=10.0) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"访问 GitHub API 失败(HTTP {exc.code}): {detail or url}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"访问 GitHub API 失败:{exc.reason}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"GitHub API 返回了无法解析的响应:{url}") from exc
|
||||
|
||||
|
||||
def _latest_release_tag(url: str, *, repo: str, prefix: str) -> Optional[str]:
|
||||
payload = _github_api_json(url, repo=repo)
|
||||
if not isinstance(payload, list):
|
||||
raise RuntimeError(f"GitHub API 返回格式异常:{url}")
|
||||
|
||||
matched_tags = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tag_name = str(item.get("tag_name") or "").strip()
|
||||
if tag_name.startswith(f"{prefix}."):
|
||||
matched_tags.append(tag_name)
|
||||
|
||||
if not matched_tags:
|
||||
return None
|
||||
return sorted(matched_tags, key=_release_sort_key)[-1]
|
||||
|
||||
|
||||
def _git_current_branch() -> Optional[str]:
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
@@ -277,33 +236,120 @@ def _git_current_branch() -> Optional[str]:
|
||||
|
||||
|
||||
def _auto_update_mode() -> str:
|
||||
one_shot_mode = SystemHelper.consume_one_shot_update_mode()
|
||||
if one_shot_mode:
|
||||
return one_shot_mode
|
||||
return SystemHelper.get_auto_update_mode()
|
||||
if SystemHelper.consume_one_shot_dev_update():
|
||||
return "dev"
|
||||
return str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file_handle:
|
||||
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _mark_prepared_update_failed(message: str) -> None:
|
||||
state = _read_json_file(PREPARED_UPDATE_STATE) or {}
|
||||
state.update(
|
||||
{
|
||||
"state": "failed",
|
||||
"error": message,
|
||||
"can_update": True,
|
||||
"can_install": False,
|
||||
}
|
||||
)
|
||||
_write_json_file(PREPARED_UPDATE_STATE, state)
|
||||
_clear_json_file(PREPARED_UPDATE_MANIFEST)
|
||||
|
||||
|
||||
def _apply_prepared_release_update() -> bool:
|
||||
"""本地 CLI 重启时离线安装已校验的 Release;返回是否发现安装意图。"""
|
||||
manifest = _read_json_file(PREPARED_UPDATE_MANIFEST)
|
||||
if not manifest:
|
||||
return False
|
||||
|
||||
try:
|
||||
version = str(manifest.get("version") or "").strip()
|
||||
frontend_version = str(manifest.get("frontend_version") or "").strip()
|
||||
backend_archive = Path(str(manifest.get("backend_archive") or ""))
|
||||
frontend_archive = Path(str(manifest.get("frontend_archive") or ""))
|
||||
if not version or not frontend_version:
|
||||
raise RuntimeError("更新包清单缺少版本信息")
|
||||
if (
|
||||
not backend_archive.is_file()
|
||||
or _file_sha256(backend_archive) != manifest.get("backend_sha256")
|
||||
):
|
||||
raise RuntimeError("后端更新包校验失败")
|
||||
if (
|
||||
not frontend_archive.is_file()
|
||||
or _file_sha256(frontend_archive) != manifest.get("frontend_sha256")
|
||||
):
|
||||
raise RuntimeError("前端更新包校验失败")
|
||||
|
||||
update_command = [
|
||||
sys.executable,
|
||||
str(_repo_root() / "scripts" / "local_setup.py"),
|
||||
"update",
|
||||
"all",
|
||||
"--ref",
|
||||
version,
|
||||
"--offline-backend",
|
||||
"--frontend-version",
|
||||
frontend_version,
|
||||
"--frontend-archive",
|
||||
str(frontend_archive),
|
||||
"--skip-resources",
|
||||
"--venv",
|
||||
str(_repo_root() / "venv"),
|
||||
"--config-dir",
|
||||
str(settings.CONFIG_PATH),
|
||||
]
|
||||
click.echo(f"安装已下载并校验的 MoviePilot {version} 更新包")
|
||||
result = subprocess.run(
|
||||
update_command,
|
||||
cwd=str(_repo_root()),
|
||||
env=os.environ.copy(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
lines = [line for line in (result.stdout or "").splitlines() if line.strip()]
|
||||
detail = lines[-1] if lines else "未知错误"
|
||||
raise RuntimeError(detail)
|
||||
|
||||
_clear_json_file(PREPARED_UPDATE_MANIFEST)
|
||||
click.echo("已下载的 Release 更新安装完成")
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
message = f"本地 Release 更新安装失败:{error}"
|
||||
_mark_prepared_update_failed(message)
|
||||
_warn(f"{message},继续使用当前版本启动")
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||
if mode != "dev":
|
||||
return None
|
||||
backend_prefix = _release_prefix(APP_VERSION)
|
||||
|
||||
if mode == "dev":
|
||||
current_branch = _git_current_branch()
|
||||
backend_ref = "latest"
|
||||
if not current_branch or current_branch == "HEAD":
|
||||
# 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。
|
||||
backend_ref = backend_prefix
|
||||
else:
|
||||
backend_ref = _latest_release_tag(
|
||||
BACKEND_RELEASES_API,
|
||||
repo="jxxghp/MoviePilot",
|
||||
prefix=backend_prefix,
|
||||
)
|
||||
current_branch = _git_current_branch()
|
||||
backend_ref = "latest"
|
||||
if not current_branch or current_branch == "HEAD":
|
||||
# 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。
|
||||
backend_ref = backend_prefix
|
||||
return backend_ref
|
||||
|
||||
|
||||
def _best_effort_auto_update() -> None:
|
||||
if _apply_prepared_release_update():
|
||||
return
|
||||
|
||||
mode = _auto_update_mode()
|
||||
if mode not in AUTO_UPDATE_ENABLED_VALUES:
|
||||
# Release 更新先在后台下载并经用户确认;这里只保留开发版分支跟踪。
|
||||
if mode != "dev":
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
+18
-2
@@ -317,8 +317,8 @@ class ConfigModel(BaseModel):
|
||||
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
||||
|
||||
# ==================== 系统升级配置 ====================
|
||||
# 重启自动升级
|
||||
MOVIEPILOT_AUTO_UPDATE: str = "release"
|
||||
# 开发版仍可在启动时跟踪 v3 分支;Release 更新由后台更新服务管理。
|
||||
MOVIEPILOT_AUTO_UPDATE: str = "false"
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
@@ -899,6 +899,22 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# Release 已迁移到后台状态机,历史 release/true 不能继续启用启动时更新。
|
||||
if "MOVIEPILOT_AUTO_UPDATE" in data:
|
||||
original_update_mode = data["MOVIEPILOT_AUTO_UPDATE"]
|
||||
normalized_update_mode = (
|
||||
"dev"
|
||||
if str(original_update_mode or "").strip().lower() == "dev"
|
||||
else "false"
|
||||
)
|
||||
if normalized_update_mode != str(original_update_mode):
|
||||
cls.update_env_config(
|
||||
"MOVIEPILOT_AUTO_UPDATE",
|
||||
original_update_mode,
|
||||
normalized_update_mode,
|
||||
)
|
||||
data["MOVIEPILOT_AUTO_UPDATE"] = normalized_update_mode
|
||||
|
||||
# 处理 API_TOKEN 特殊验证
|
||||
if "API_TOKEN" in data:
|
||||
converted_value, needs_update = cls.validate_api_token(
|
||||
|
||||
+31
-99
@@ -23,7 +23,6 @@ class SystemHelper(ConfigReloadMixin):
|
||||
"""
|
||||
系统工具类,提供系统相关的操作和判断
|
||||
"""
|
||||
AUTO_UPDATE_ENABLED_VALUES = {"release", "dev"}
|
||||
CONFIG_WATCH = {
|
||||
"DEBUG",
|
||||
"LOG_LEVEL",
|
||||
@@ -36,7 +35,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
__system_flag_file = "/var/log/nginx/__moviepilot__"
|
||||
__local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
__local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log"
|
||||
__one_shot_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_update"
|
||||
__one_shot_dev_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_dev_update"
|
||||
__docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart"
|
||||
__graceful_shutdown_monitor_lock = threading.Lock()
|
||||
__graceful_shutdown_monitor: Optional[threading.Thread] = None
|
||||
@@ -97,94 +96,41 @@ class SystemHelper(ConfigReloadMixin):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def normalize_auto_update_mode(mode: Optional[str]) -> str:
|
||||
"""
|
||||
统一自动升级模式值,兼容历史 true 表示 release。
|
||||
"""
|
||||
normalized = str(mode or "").strip().lower()
|
||||
return "release" if normalized == "true" else normalized
|
||||
|
||||
@staticmethod
|
||||
def get_auto_update_mode() -> str:
|
||||
"""
|
||||
获取当前配置中的自动升级模式。
|
||||
"""
|
||||
return SystemHelper.normalize_auto_update_mode(
|
||||
settings.MOVIEPILOT_AUTO_UPDATE
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_auto_update_enabled(mode: Optional[str] = None) -> bool:
|
||||
"""
|
||||
判断给定模式或当前配置是否启用了启动时自动升级。
|
||||
"""
|
||||
effective_mode = (
|
||||
SystemHelper.get_auto_update_mode()
|
||||
if mode is None
|
||||
else SystemHelper.normalize_auto_update_mode(mode)
|
||||
)
|
||||
return effective_mode in SystemHelper.AUTO_UPDATE_ENABLED_VALUES
|
||||
|
||||
@staticmethod
|
||||
def queue_one_shot_update(mode: str = "release") -> Tuple[bool, str]:
|
||||
"""
|
||||
写入一次性升级标记,供重启后的启动流程消费。
|
||||
"""
|
||||
effective_mode = SystemHelper.normalize_auto_update_mode(mode)
|
||||
if effective_mode not in SystemHelper.AUTO_UPDATE_ENABLED_VALUES:
|
||||
return False, "升级模式仅支持 release 或 dev"
|
||||
|
||||
def queue_one_shot_dev_update() -> Tuple[bool, str]:
|
||||
"""写入一次性 Dev 更新标记,供本次重启的启动流程消费。"""
|
||||
try:
|
||||
SystemHelper.__one_shot_update_flag_file.parent.mkdir(
|
||||
SystemHelper.__one_shot_dev_update_flag_file.parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
SystemHelper.__one_shot_update_flag_file.write_text(
|
||||
effective_mode, encoding="utf-8"
|
||||
SystemHelper.__one_shot_dev_update_flag_file.write_text(
|
||||
"dev", encoding="utf-8"
|
||||
)
|
||||
logger.info(f"已写入一次性升级标记,模式: {effective_mode}")
|
||||
return True, ""
|
||||
except OSError as err:
|
||||
logger.error(f"写入一次性升级标记失败: {err}")
|
||||
return False, f"写入一次性升级标记失败:{err}"
|
||||
logger.error(f"写入一次性 Dev 更新标记失败: {err}")
|
||||
return False, f"写入一次性 Dev 更新标记失败:{err}"
|
||||
|
||||
@staticmethod
|
||||
def consume_one_shot_update_mode() -> Optional[str]:
|
||||
"""
|
||||
读取并清除一次性升级标记,避免后续启动重复执行。
|
||||
"""
|
||||
path = SystemHelper.__one_shot_update_flag_file
|
||||
def consume_one_shot_dev_update() -> bool:
|
||||
"""读取并删除一次性 Dev 更新标记,确保普通重启不会重复更新。"""
|
||||
path = SystemHelper.__one_shot_dev_update_flag_file
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
raw_mode = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as err:
|
||||
logger.warning(f"读取一次性升级标记失败: {err}")
|
||||
raw_mode = ""
|
||||
|
||||
return False
|
||||
try:
|
||||
mode = path.read_text(encoding="utf-8", errors="replace").strip().lower()
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
logger.warning(f"删除一次性升级标记失败: {err}")
|
||||
|
||||
effective_mode = SystemHelper.normalize_auto_update_mode(raw_mode)
|
||||
if effective_mode not in SystemHelper.AUTO_UPDATE_ENABLED_VALUES:
|
||||
if raw_mode:
|
||||
logger.warning(f"忽略无效的一次性升级模式: {raw_mode}")
|
||||
return None
|
||||
|
||||
logger.info(f"检测到一次性升级标记,模式: {effective_mode}")
|
||||
return effective_mode
|
||||
logger.warning(f"消费一次性 Dev 更新标记失败: {err}")
|
||||
return False
|
||||
return mode == "dev"
|
||||
|
||||
@staticmethod
|
||||
def clear_one_shot_update_flag() -> None:
|
||||
"""
|
||||
删除一次性升级标记。
|
||||
"""
|
||||
def clear_one_shot_dev_update() -> None:
|
||||
"""重启失败时撤销尚未消费的一次性 Dev 更新。"""
|
||||
try:
|
||||
SystemHelper.__one_shot_update_flag_file.unlink(missing_ok=True)
|
||||
SystemHelper.__one_shot_dev_update_flag_file.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
logger.warning(f"删除一次性升级标记失败: {err}")
|
||||
logger.warning(f"清理一次性 Dev 更新标记失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def _spawn_local_restart_helper() -> None:
|
||||
@@ -332,32 +278,18 @@ class SystemHelper(ConfigReloadMixin):
|
||||
return SystemHelper._docker_api_restart()
|
||||
|
||||
@staticmethod
|
||||
def upgrade(mode: str = "release") -> Tuple[bool, str]:
|
||||
"""
|
||||
触发升级并重启。
|
||||
|
||||
- 已开启自动升级时,直接重启,沿用当前配置。
|
||||
- 未开启自动升级时,写入一次性升级标记,供下次启动时执行升级。
|
||||
"""
|
||||
current_mode = SystemHelper.get_auto_update_mode()
|
||||
if SystemHelper.is_auto_update_enabled(current_mode):
|
||||
ret, msg = SystemHelper.restart()
|
||||
if not ret:
|
||||
return ret, msg
|
||||
if current_mode == "dev":
|
||||
return True, "已检测到自动升级模式 dev,正在重启并执行升级"
|
||||
return True, "已检测到自动升级已开启,正在重启并执行升级"
|
||||
|
||||
queued, message = SystemHelper.queue_one_shot_update(mode)
|
||||
if not queued:
|
||||
return False, message
|
||||
|
||||
ret, msg = SystemHelper.restart()
|
||||
def upgrade_dev() -> Tuple[bool, str]:
|
||||
"""保留原 Dev 模式:重启后跟踪当前 v3 开发分支。"""
|
||||
configured_mode = str(settings.MOVIEPILOT_AUTO_UPDATE or "").strip().lower()
|
||||
if configured_mode != "dev":
|
||||
queued, message = SystemHelper.queue_one_shot_dev_update()
|
||||
if not queued:
|
||||
return False, message
|
||||
ret, message = SystemHelper.restart()
|
||||
if not ret:
|
||||
SystemHelper.clear_one_shot_update_flag()
|
||||
return ret, msg
|
||||
effective_mode = SystemHelper.normalize_auto_update_mode(mode)
|
||||
return True, f"已安排一次性 {effective_mode} 升级并重启"
|
||||
SystemHelper.clear_one_shot_dev_update()
|
||||
return False, message
|
||||
return True, "已安排 Dev 更新并重启"
|
||||
|
||||
@staticmethod
|
||||
def _start_graceful_shutdown_monitor():
|
||||
|
||||
@@ -42,6 +42,7 @@ from app.application.mediaserver import get_mediaserver_configs
|
||||
from app.application.messaging.message import MessageHelper
|
||||
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.system.update import system_update_manager
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.message import Message
|
||||
@@ -556,6 +557,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
|
||||
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
|
||||
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
|
||||
JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system"),
|
||||
]).runtime_states()
|
||||
for job_id, job in self._jobs.items():
|
||||
self._assign_job_generation(job_id, job)
|
||||
@@ -777,6 +779,17 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
kwargs={"job_id": "plugin_market_refresh"},
|
||||
)
|
||||
|
||||
# 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="system_update_check",
|
||||
name="检查系统更新",
|
||||
hours=6,
|
||||
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=1),
|
||||
kwargs={"job_id": "system_update_check"},
|
||||
)
|
||||
|
||||
# 订阅日历缓存
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
|
||||
@@ -364,6 +364,7 @@ SCHEMA_EXPORTS = {
|
||||
'SystemErrorEventData': ('app.schemas.event', 'SystemErrorEventData'),
|
||||
'SystemModuleInfo': ('app.schemas.system', 'SystemModuleInfo'),
|
||||
'SystemModuleListData': ('app.schemas.system', 'SystemModuleListData'),
|
||||
'SystemUpdateStatus': ('app.schemas.system', 'SystemUpdateStatus'),
|
||||
'TMDbException': ('app.schemas.exception', 'TMDbException'),
|
||||
'Tag': ('app.schemas.context', 'Tag'),
|
||||
'TimeData': ('app.schemas.common', 'TimeData'),
|
||||
@@ -448,7 +449,7 @@ SCHEMA_CONFLICTS = {
|
||||
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
|
||||
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
|
||||
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.mcp'],
|
||||
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
|
||||
|
||||
+27
-1
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
from typing import Optional, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@@ -142,6 +142,32 @@ class SystemEnvironmentUpdateData(BaseModel):
|
||||
failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemUpdateStatus(BaseModel):
|
||||
"""主程序后台更新的可恢复状态快照。"""
|
||||
|
||||
state: Literal[
|
||||
"idle",
|
||||
"available",
|
||||
"downloading",
|
||||
"ready",
|
||||
"installing",
|
||||
"failed",
|
||||
] = "idle"
|
||||
current_version: str
|
||||
version: Optional[str] = None
|
||||
frontend_version: Optional[str] = None
|
||||
release_name: Optional[str] = None
|
||||
release_notes: Optional[str] = None
|
||||
published_at: Optional[str] = None
|
||||
checked_at: Optional[str] = None
|
||||
downloaded_bytes: int = 0
|
||||
total_bytes: int = 0
|
||||
progress: int = 0
|
||||
error: Optional[str] = None
|
||||
can_update: bool = False
|
||||
can_install: bool = False
|
||||
|
||||
|
||||
class PluginMarketSyncData(BaseModel):
|
||||
"""Wiki 插件市场仓库同步结果。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user