diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index f9ddff8e..4157ac76 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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) diff --git a/app/cli.py b/app/cli.py index bf2f9a8f..491cd817 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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]]: diff --git a/app/core/config.py b/app/core/config.py index da17c4c2..3361fd86 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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): """ diff --git a/app/helper/system.py b/app/helper/system.py index 61d5685c..38898ef5 100644 --- a/app/helper/system.py +++ b/app/helper/system.py @@ -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(): """ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0f207f61..8582bce7 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 < 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 ``` -Or with curl: -```bash -curl -X GET "http://localhost:3000/api/v1/system/restart" \ - -H "X-API-KEY: " -``` +## 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. diff --git a/skills/moviepilot-update/scripts/mp-update.py b/skills/moviepilot-update/scripts/mp-update.py new file mode 100644 index 00000000..ba3b70e9 --- /dev/null +++ b/skills/moviepilot-update/scripts/mp-update.py @@ -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())