feat(system): implement one-shot upgrade mode and enhance upgrade handling

This commit is contained in:
jxxghp
2026-05-05 15:22:33 +08:00
parent 27436757a0
commit caf615f3bd
8 changed files with 280 additions and 109 deletions

View File

@@ -950,6 +950,30 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
global_vars.stop_system()
# 执行重启
ret, msg = SystemHelper.restart()
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response)
def upgrade_system(
mode: Annotated[str | None, Body()] = None,
_: User = Depends(get_current_active_superuser),
):
"""
触发系统升级并重启(仅管理员)
- 当前已开启自动升级时:直接重启,由启动流程完成升级。
- 当前未开启自动升级时:写入一次性升级标记,本次重启后仅执行一次升级。
"""
if not SystemHelper.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持升级操作!")
# 标识停止事件
global_vars.stop_system()
ret, msg = SystemHelper.upgrade(mode=mode or "release")
if not ret:
global_vars.resume_system()
return schemas.Response(success=ret, message=msg)

View File

@@ -16,6 +16,7 @@ import click
import psutil
from app.core.config import Settings, settings
from app.helper.system import SystemHelper
from version import APP_VERSION
BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json"
@@ -272,7 +273,10 @@ def _git_current_branch() -> Optional[str]:
def _auto_update_mode() -> str:
return str(getattr(settings, "MOVIEPILOT_AUTO_UPDATE", "") or "").strip().lower()
one_shot_mode = SystemHelper.consume_one_shot_update_mode()
if one_shot_mode:
return one_shot_mode
return SystemHelper.get_auto_update_mode()
def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str]]:

View File

@@ -1066,6 +1066,12 @@ class GlobalVar(object):
"""
self.STOP_EVENT.set()
def resume_system(self):
"""
恢复系统运行标记。
"""
self.STOP_EVENT.clear()
@property
def is_system_stopped(self):
"""

View File

@@ -21,6 +21,7 @@ class SystemHelper(ConfigReloadMixin):
"""
系统工具类,提供系统相关的操作和判断
"""
AUTO_UPDATE_ENABLED_VALUES = {"release", "dev"}
CONFIG_WATCH = {
"DEBUG",
"LOG_LEVEL",
@@ -33,6 +34,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"
def on_config_changed(self):
logger.update_loggers()
@@ -85,6 +87,96 @@ class SystemHelper(ConfigReloadMixin):
except (psutil.Error, TypeError, ValueError):
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"
try:
SystemHelper.__one_shot_update_flag_file.parent.mkdir(
parents=True, exist_ok=True
)
SystemHelper.__one_shot_update_flag_file.write_text(
effective_mode, encoding="utf-8"
)
logger.info(f"已写入一次性升级标记,模式: {effective_mode}")
return True, ""
except OSError as err:
logger.error(f"写入一次性升级标记失败: {err}")
return False, f"写入一次性升级标记失败:{err}"
@staticmethod
def consume_one_shot_update_mode() -> Optional[str]:
"""
读取并清除一次性升级标记,避免后续启动重复执行。
"""
path = SystemHelper.__one_shot_update_flag_file
if not path.exists():
return None
try:
raw_mode = path.read_text(encoding="utf-8")
except OSError as err:
logger.warning(f"读取一次性升级标记失败: {err}")
raw_mode = ""
try:
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
@staticmethod
def clear_one_shot_update_flag() -> None:
"""
删除一次性升级标记。
"""
try:
SystemHelper.__one_shot_update_flag_file.unlink(missing_ok=True)
except OSError as err:
logger.warning(f"删除一次性升级标记失败: {err}")
@staticmethod
def _spawn_local_restart_helper() -> None:
helper_code = (
@@ -178,6 +270,8 @@ class SystemHelper(ConfigReloadMixin):
return False, "当前实例不是由 moviepilot CLI 启动,无法执行内建重启!"
try:
SystemHelper._spawn_local_restart_helper()
# 复用与 Docker 相同的优雅退出路径,确保当前后端进程真正结束。
os.kill(os.getpid(), signal.SIGTERM)
return True, ""
except Exception as err:
logger.error(f"本地 CLI 重启失败: {str(err)}")
@@ -204,6 +298,34 @@ class SystemHelper(ConfigReloadMixin):
logger.warning("降级为Docker API重启...")
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()
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} 升级并重启"
@staticmethod
def _start_graceful_shutdown_monitor():
"""

View File

@@ -219,6 +219,25 @@ function graceful_exit() {
# 使用env配置
load_config_from_app_env
# 一次性升级标记仅影响本次启动,避免把临时升级模式带入运行中的 Python 进程
ONE_SHOT_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_update"
ONE_SHOT_UPDATE_APPLIED="false"
MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}"
if [ -f "${ONE_SHOT_UPDATE_FLAG}" ]; then
ONE_SHOT_UPDATE_MODE="$(tr -d '\r\n' < "${ONE_SHOT_UPDATE_FLAG}" | tr '[:upper:]' '[:lower:]')"
rm -f "${ONE_SHOT_UPDATE_FLAG}"
if [ "${ONE_SHOT_UPDATE_MODE}" = "true" ]; then
ONE_SHOT_UPDATE_MODE="release"
fi
if [ "${ONE_SHOT_UPDATE_MODE}" = "release" ] || [ "${ONE_SHOT_UPDATE_MODE}" = "dev" ]; then
INFO "检测到一次性升级标记,本次启动将执行 ${ONE_SHOT_UPDATE_MODE} 升级..."
export 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
# 生成HTTPS配置块
if [ "${ENABLE_SSL}" = "true" ]; then
export HTTPS_SERVER_CONF=$(cat <<EOF
@@ -256,6 +275,9 @@ envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${ENABLE_SSL}${HTTPS_
# 自动更新
cd /
source /usr/local/bin/mp_update.sh
if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then
export MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
fi
cd /app || exit
# 更改 moviepilot userid 和 groupid

View File

@@ -310,7 +310,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
### System (20 endpoints)
### System (21 endpoints)
| Method | Path | Description |
|--------|------|-------------|
@@ -321,6 +321,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
| GET | `/api/v1/system/global/user` | User-related settings |
| GET | `/api/v1/system/restart` | Restart system |
| POST | `/api/v1/system/upgrade` | Upgrade and restart system. Body: `"release"` or `"dev"` |
| GET | `/api/v1/system/runscheduler` | Run scheduled service. Params: `jobid` (required) |
| GET | `/api/v1/system/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` |
| GET | `/api/v1/system/modulelist` | List loaded modules |

View File

@@ -1,144 +1,74 @@
---
name: moviepilot-update
version: 1
description: Use this skill when you need to restart or upgrade MoviePilot. This skill covers system restart, version check, and manual upgrade procedures.
version: 2
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.
---
# MoviePilot System Update & Restart
# MoviePilot Update
> All script paths are relative to this skill file.
This skill provides capabilities to restart MoviePilot service, check for updates, and perform manual upgrades.
Use this skill for MoviePilot restart and upgrade operations.
## Restart MoviePilot
## Setup
### Method 1: Using REST API (Recommended)
This skill reuses the `moviepilot-api` client configuration.
Call the restart endpoint with admin authentication:
Configure host and API key once:
```bash
# Using moviepilot-api skill
python scripts/mp-api.py GET /api/v1/system/restart
python ../moviepilot-api/scripts/mp-api.py configure --host http://localhost:3000 --apikey <API_TOKEN>
```
Or with curl:
```bash
curl -X GET "http://localhost:3000/api/v1/system/restart" \
-H "X-API-KEY: <YOUR_API_TOKEN>"
```
## Preferred Commands
**Note:** This API will restart the Docker container internally. The service will be briefly unavailable during restart.
### Method 2: Using execute_command tool
If you have admin privileges, you can execute the restart command directly:
### Check versions
```bash
docker restart moviepilot
python scripts/mp-update.py versions
```
## Check for Updates
This calls `GET /api/v1/system/versions`.
### Method 1: Using REST API
### Restart MoviePilot
```bash
python scripts/mp-api.py GET /api/v1/system/versions
python scripts/mp-update.py restart
```
This returns all available GitHub releases.
This calls `GET /api/v1/system/restart`.
### Method 2: Check current version
### Upgrade and restart MoviePilot
Release mode:
```bash
# Check current version
cat /app/version.py
python scripts/mp-update.py upgrade
```
## Upgrade MoviePilot
Dev mode:
### Option 1: Automatic Update (Recommended)
```bash
python scripts/mp-update.py upgrade dev
```
Set the environment variable `MOVIEPILOT_AUTO_UPDATE` and restart:
This calls `POST /api/v1/system/upgrade`.
1. **For Docker Compose users:**
```bash
# Edit docker-compose.yml, add environment variable:
environment:
- MOVIEPILOT_AUTO_UPDATE=release # or "dev" for dev版本
# Then restart
docker-compose down && docker-compose up -d
```
Behavior:
2. **For Docker run users:**
```bash
docker stop moviepilot
docker rm moviepilot
docker run -d ... -e MOVIEPILOT_AUTO_UPDATE=release jxxghp/moviepilot
```
- 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.
The update script (`/usr/local/bin/mp_update.sh` or `/app/docker/update.sh`) will automatically:
- Check GitHub for latest release
- Download new backend code
- Update dependencies if changed
- Download new frontend
- Update site resources
- Restart the service
## Direct API Examples
### Option 2: Manual Upgrade
```bash
python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/restart
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"release"'
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"dev"'
```
If you need to manually download and apply updates:
## Notes
1. **Get latest release version:**
```bash
curl -s https://api.github.com/repos/jxxghp/MoviePilot/releases | grep '"tag_name"' | grep "v2" | head -1
```
2. **Download and extract backend:**
```bash
# Replace v2.x.x with actual version
curl -L -o /tmp/backend.zip https://github.com/jxxghp/MoviePilot/archive/refs/tags/v2.x.x.zip
unzip -d /tmp/backend /tmp/backend.zip
```
3. **Backup and replace:**
```bash
# Backup current installation
cp -r /app /app_backup
# Replace files (exclude config and plugins)
cp -r /tmp/backend/MoviePilot-*/* /app/
```
4. **Restart MoviePilot:**
```bash
# Use API or docker restart
python scripts/mp-api.py GET /api/v1/system/restart
```
### Important Notes
- **Backup first:** Before upgrading, backup your configuration and database
- **Dependencies:** Check if requirements.in has changes; if so, update virtual environment
- **Plugins:** The update script automatically backs up and restores plugins
- **Non-Docker:** For non-Docker installations, use `git pull` or `pip install -U moviepilot`
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Restart fails | Check if Docker daemon is running; verify container has restart policy |
| Update fails | Check network connectivity to GitHub; ensure sufficient disk space |
| Version unchanged | Verify `MOVIEPILOT_AUTO_UPDATE` environment variable is set correctly |
| Dependency errors | May need to rebuild virtual environment: `pip-compile requirements.in && pip install -r requirements.txt` |
## Environment Variables for Auto-Update
| Variable | Value | Description |
|----------|-------|-------------|
| `MOVIEPILOT_AUTO_UPDATE` | `release` | Auto-update to latest stable release |
| `MOVIEPILOT_AUTO_UPDATE` | `dev` | Auto-update to latest dev version |
| `MOVIEPILOT_AUTO_UPDATE` | `false` | Disable auto-update (default) |
| `GITHUB_TOKEN` | (token) | GitHub token for higher rate limits |
| `GITHUB_PROXY` | (url) | GitHub proxy URL for China users |
| `PROXY_HOST` | (url) | Global proxy host |
- 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.
- Prefer the API flow above. Only fall back to manual container commands when the API is unavailable.

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
API_SCRIPT = SCRIPT_DIR.parents[1] / "moviepilot-api" / "scripts" / "mp-api.py"
def run_api_call(args: list[str]) -> int:
command = [sys.executable, str(API_SCRIPT), *args]
return_code = __import__("subprocess").run(command, check=False).returncode
return return_code
def print_usage() -> None:
print(
"Usage:\n"
f" python {Path(sys.argv[0]).name} versions\n"
f" python {Path(sys.argv[0]).name} restart\n"
f" python {Path(sys.argv[0]).name} upgrade [release|dev]"
)
def main() -> int:
argv = sys.argv[1:]
if not argv or argv[0] in {"-h", "--help", "help"}:
print_usage()
return 0
command = argv[0].lower()
if command == "versions":
return run_api_call(["GET", "/api/v1/system/versions"])
if command == "restart":
return run_api_call(["GET", "/api/v1/system/restart"])
if command == "upgrade":
mode = (argv[1] if len(argv) > 1 else "release").strip().lower()
if mode == "true":
mode = "release"
if mode not in {"release", "dev"}:
print("Error: mode must be release or dev", file=sys.stderr)
return 1
return run_api_call([
"POST",
"/api/v1/system/upgrade",
"--json",
json.dumps(mode, ensure_ascii=False),
])
print(f"Error: unknown command: {command}", file=sys.stderr)
print_usage()
return 1
if __name__ == "__main__":
sys.exit(main())