mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-06 20:42:43 +08:00
feat: improve local CLI startup management
This commit is contained in:
217
app/cli.py
217
app/cli.py
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -9,7 +10,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 Request, urlopen
|
||||
from urllib.request import ProxyHandler, Request, build_opener, urlopen
|
||||
|
||||
import click
|
||||
import psutil
|
||||
@@ -28,7 +29,11 @@ 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"
|
||||
FRONTEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/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",
|
||||
@@ -199,6 +204,173 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float =
|
||||
return False, None
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
click.secho(message, fg="yellow")
|
||||
|
||||
|
||||
def _release_prefix(version: Optional[str]) -> str:
|
||||
"""
|
||||
从版本号中提取主版本前缀,用于把本地自动更新限制在当前主版本线上。
|
||||
"""
|
||||
matched = re.match(r"^(v\d+)", str(version or "").strip())
|
||||
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": "MoviePilot-CLI",
|
||||
}
|
||||
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="ignore")
|
||||
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(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=str(_repo_root()),
|
||||
text=True,
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
return branch or None
|
||||
|
||||
|
||||
def _auto_update_mode() -> str:
|
||||
return str(getattr(settings, "MOVIEPILOT_AUTO_UPDATE", "") or "").strip().lower()
|
||||
|
||||
|
||||
def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str]]:
|
||||
backend_prefix = _release_prefix(APP_VERSION)
|
||||
frontend_prefix = _release_prefix(_installed_frontend_version() or 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,
|
||||
)
|
||||
|
||||
frontend_version = _latest_release_tag(
|
||||
FRONTEND_RELEASES_API,
|
||||
repo="jxxghp/MoviePilot-Frontend",
|
||||
prefix=frontend_prefix,
|
||||
)
|
||||
return backend_ref, frontend_version
|
||||
|
||||
|
||||
def _best_effort_auto_update() -> None:
|
||||
mode = _auto_update_mode()
|
||||
if mode not in AUTO_UPDATE_ENABLED_VALUES:
|
||||
return
|
||||
|
||||
try:
|
||||
backend_ref, frontend_version = _resolve_auto_update_targets(mode)
|
||||
except RuntimeError as exc:
|
||||
_warn(f"自动更新准备失败,继续使用当前版本启动:{exc}")
|
||||
return
|
||||
|
||||
if not backend_ref or not frontend_version:
|
||||
_warn("自动更新准备失败,未能解析当前主版本对应的远端版本,继续使用当前版本启动")
|
||||
return
|
||||
|
||||
update_command = [
|
||||
sys.executable,
|
||||
str(_repo_root() / "scripts" / "local_setup.py"),
|
||||
"update",
|
||||
"all",
|
||||
"--ref",
|
||||
backend_ref,
|
||||
"--frontend-version",
|
||||
frontend_version,
|
||||
"--venv",
|
||||
str(_repo_root() / "venv"),
|
||||
"--config-dir",
|
||||
str(settings.CONFIG_PATH),
|
||||
]
|
||||
|
||||
update_env = os.environ.copy()
|
||||
if settings.PROXY_HOST:
|
||||
update_env.setdefault("http_proxy", settings.PROXY_HOST)
|
||||
update_env.setdefault("https_proxy", settings.PROXY_HOST)
|
||||
update_env.setdefault("HTTP_PROXY", settings.PROXY_HOST)
|
||||
update_env.setdefault("HTTPS_PROXY", settings.PROXY_HOST)
|
||||
if settings.GITHUB_TOKEN:
|
||||
update_env.setdefault("GITHUB_TOKEN", settings.GITHUB_TOKEN)
|
||||
|
||||
click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新")
|
||||
result = subprocess.run(
|
||||
update_command,
|
||||
cwd=str(_repo_root()),
|
||||
env=update_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
click.echo("本地自动更新完成")
|
||||
return
|
||||
|
||||
output_lines = [line for line in (result.stdout or "").splitlines() if line.strip()]
|
||||
tail = output_lines[-1] if output_lines else "未知错误"
|
||||
_warn(f"本地自动更新失败,继续使用当前版本启动:{tail}")
|
||||
|
||||
|
||||
def _ensure_frontend_not_running_alone(timeout: int) -> None:
|
||||
"""
|
||||
如果只检测到 CLI 管理的前端仍在运行,则先停掉它,再按统一顺序重启前后端。
|
||||
"""
|
||||
backend_state, _, _, _ = _managed_backend_status()
|
||||
frontend_state, _, _, _ = _managed_frontend_status()
|
||||
if backend_state == "stopped" and frontend_state in MANAGED_ACTIVE_STATES:
|
||||
click.echo("检测到仅前端仍在运行,先停止前端后再整体启动")
|
||||
_stop_frontend_service(timeout=timeout, force=True)
|
||||
|
||||
|
||||
def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
|
||||
runtime = _backend_runtime()
|
||||
process = _get_process(runtime)
|
||||
@@ -431,18 +603,27 @@ def _ensure_local_api_token() -> bool:
|
||||
return result is True
|
||||
|
||||
|
||||
def _spawn_process(command: list[str], *, cwd: Path, log_file: Path, env: Optional[Dict[str, str]] = None) -> subprocess.Popen:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_handle = log_file.open("a", encoding="utf-8")
|
||||
|
||||
def _spawn_process(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
log_file: Optional[Path],
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
) -> subprocess.Popen:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"cwd": str(cwd),
|
||||
"stdout": log_handle,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"close_fds": True,
|
||||
"env": env or os.environ.copy(),
|
||||
}
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_handle = log_file.open("a", encoding="utf-8")
|
||||
kwargs["stdout"] = log_handle
|
||||
kwargs["stderr"] = subprocess.STDOUT
|
||||
else:
|
||||
kwargs["stdout"] = subprocess.DEVNULL
|
||||
kwargs["stderr"] = subprocess.DEVNULL
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
else:
|
||||
@@ -454,8 +635,19 @@ def _spawn_backend_process() -> subprocess.Popen:
|
||||
return _spawn_process(
|
||||
[sys.executable, "-m", "app.main"],
|
||||
cwd=_repo_root(),
|
||||
log_file=BACKEND_STDIO_LOG_FILE,
|
||||
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
||||
log_file=None,
|
||||
env={
|
||||
**os.environ,
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MOVIEPILOT_DISABLE_CONSOLE_LOG": "1",
|
||||
"MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE),
|
||||
"MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(
|
||||
max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024
|
||||
),
|
||||
"MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(
|
||||
max(int(settings.LOG_BACKUP_COUNT or 0), 0)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -649,6 +841,12 @@ def cli() -> None:
|
||||
@click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数")
|
||||
def start(timeout: int) -> None:
|
||||
"""后台启动本地 MoviePilot 前后端服务"""
|
||||
_ensure_frontend_not_running_alone(timeout=min(timeout, 15))
|
||||
backend_state, _, _, _ = _managed_backend_status()
|
||||
frontend_state, _, _, _ = _managed_frontend_status()
|
||||
if backend_state == "stopped" and frontend_state == "stopped":
|
||||
_best_effort_auto_update()
|
||||
|
||||
backend_result = _start_backend_service(timeout=timeout)
|
||||
backend_runtime = backend_result["runtime"]
|
||||
try:
|
||||
@@ -699,6 +897,7 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
|
||||
"""重启本地 MoviePilot 前后端服务"""
|
||||
_stop_frontend_service(timeout=stop_timeout, force=force)
|
||||
_stop_backend_service(timeout=stop_timeout, force=force)
|
||||
_best_effort_auto_update()
|
||||
backend_result = _start_backend_service(timeout=start_timeout)
|
||||
frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]))
|
||||
click.echo("MoviePilot 已重启")
|
||||
|
||||
12
app/log.py
12
app/log.py
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
@@ -407,11 +408,12 @@ class LoggerManager:
|
||||
for handler in _logger.handlers:
|
||||
_logger.removeHandler(handler)
|
||||
|
||||
# 只设置终端日志(文件日志由 NonBlockingFileHandler 处理)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
# 本地 CLI 已经有独立的 stdio 滚动日志时,不再把业务日志重复打一份到控制台。
|
||||
if os.getenv("MOVIEPILOT_DISABLE_CONSOLE_LOG") != "1":
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
|
||||
# 禁止向父级log传递
|
||||
_logger.propagate = False
|
||||
|
||||
17
app/main.py
17
app/main.py
@@ -4,19 +4,32 @@ import setproctitle
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn as uvicorn
|
||||
from PIL import Image
|
||||
from uvicorn import Config
|
||||
|
||||
from app.factory import app
|
||||
from app.utils.stdio import configure_rotating_stdio
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
# 禁用输出
|
||||
if SystemUtils.is_frozen():
|
||||
stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE")
|
||||
if stdio_log_file:
|
||||
# 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。
|
||||
configure_rotating_stdio(
|
||||
log_file=Path(stdio_log_file),
|
||||
max_bytes=max(int(os.getenv("MOVIEPILOT_STDIO_LOG_MAX_BYTES", "0") or 0), 1),
|
||||
backup_count=max(
|
||||
int(os.getenv("MOVIEPILOT_STDIO_LOG_BACKUP_COUNT", "0") or 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
elif SystemUtils.is_frozen():
|
||||
sys.stdout = open(os.devnull, 'w')
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
from app.factory import app
|
||||
from app.core.config import settings
|
||||
from app.db.init import init_db, update_db
|
||||
|
||||
|
||||
84
app/utils/stdio.py
Normal file
84
app/utils/stdio.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RotatingLineStream(io.TextIOBase):
|
||||
"""
|
||||
将 stdout/stderr 按行写入滚动日志文件。
|
||||
|
||||
这里不复用业务 logger,避免 stdout 日志再次回流到控制台或普通业务日志文件,
|
||||
同时保证启动阶段的 print/uvicorn 输出也能按配置滚动。
|
||||
"""
|
||||
|
||||
def __init__(self, log_file: Path, max_bytes: int, backup_count: int):
|
||||
super().__init__()
|
||||
self._buffer = ""
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger_name = f"moviepilot-stdio::{log_file}"
|
||||
self._logger = logging.getLogger(logger_name)
|
||||
self._logger.setLevel(logging.INFO)
|
||||
self._logger.propagate = False
|
||||
self._logger.handlers.clear()
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
filename=str(log_file),
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
self._logger.addHandler(handler)
|
||||
|
||||
@property
|
||||
def encoding(self) -> str:
|
||||
return "utf-8"
|
||||
|
||||
def writable(self) -> bool:
|
||||
return True
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, message: str) -> int:
|
||||
if not message:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
self._buffer += message.replace("\r\n", "\n")
|
||||
while "\n" in self._buffer:
|
||||
line, self._buffer = self._buffer.split("\n", 1)
|
||||
self._logger.info(line)
|
||||
return len(message)
|
||||
|
||||
def flush(self) -> None:
|
||||
with self._lock:
|
||||
if self._buffer:
|
||||
self._logger.info(self._buffer)
|
||||
self._buffer = ""
|
||||
for handler in self._logger.handlers:
|
||||
handler.flush()
|
||||
|
||||
|
||||
def configure_rotating_stdio(
|
||||
*, log_file: Path, max_bytes: int, backup_count: int
|
||||
) -> RotatingLineStream:
|
||||
"""
|
||||
将当前进程的 stdout/stderr 统一重定向到同一个滚动日志流。
|
||||
"""
|
||||
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
stream = RotatingLineStream(
|
||||
log_file=log_file,
|
||||
max_bytes=max_bytes,
|
||||
backup_count=backup_count,
|
||||
)
|
||||
sys.stdout = stream
|
||||
sys.stderr = stream
|
||||
return stream
|
||||
28
docs/cli.md
28
docs/cli.md
@@ -64,6 +64,7 @@ moviepilot config path
|
||||
- 前端本地 Node 运行时:`.runtime/node/`
|
||||
- 后端日志:`<Config Dir>/logs/moviepilot.log`
|
||||
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
|
||||
该文件同样受 `LOG_MAX_FILE_SIZE` 与 `LOG_BACKUP_COUNT` 控制
|
||||
- 前端启动日志:`<Config Dir>/logs/moviepilot.frontend.stdout.log`
|
||||
|
||||
## 帮助与发现
|
||||
@@ -118,6 +119,9 @@ moviepilot uninstall
|
||||
moviepilot update backend
|
||||
moviepilot update frontend
|
||||
moviepilot update all
|
||||
moviepilot startup enable
|
||||
moviepilot startup disable
|
||||
moviepilot startup status
|
||||
moviepilot agent
|
||||
moviepilot start
|
||||
moviepilot stop
|
||||
@@ -232,6 +236,8 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
||||
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
||||
- 用户站点认证
|
||||
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
||||
- 开机自启
|
||||
可按需启用,MoviePilot 会根据当前操作系统注册登录自启动
|
||||
- 下载器
|
||||
- 媒体服务器
|
||||
- 消息通知渠道
|
||||
@@ -248,6 +254,25 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
||||
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
||||
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
|
||||
|
||||
## 开机自启命令
|
||||
|
||||
管理当前本地安装的开机自启:
|
||||
|
||||
```shell
|
||||
moviepilot startup status
|
||||
moviepilot startup enable
|
||||
moviepilot startup disable
|
||||
moviepilot startup enable --venv /path/to/venv
|
||||
moviepilot startup enable --config-dir /path/to/moviepilot-config
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- macOS 使用 `LaunchAgent`
|
||||
- Linux 优先使用 `systemd --user`,当前环境不可用时自动回退到 `XDG autostart`
|
||||
- Windows 使用当前用户的 Startup 启动目录
|
||||
- 注册的启动项会调用本地 CLI 的统一启动入口,因此会同时拉起后端与前端
|
||||
|
||||
## 卸载命令
|
||||
|
||||
卸载本地安装产物:
|
||||
@@ -262,6 +287,7 @@ moviepilot uninstall --config-dir /path/to/moviepilot-config
|
||||
|
||||
- 卸载时会先停止当前 CLI 管理的前后端服务
|
||||
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
|
||||
- 如果之前注册过开机自启,卸载时也会一并取消
|
||||
- 会询问是否同时删除配置目录,默认不删除
|
||||
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
|
||||
- 整个卸载流程包含两次确认
|
||||
@@ -338,10 +364,12 @@ moviepilot version
|
||||
说明:
|
||||
|
||||
- `start` 会先启动后端,再启动前端
|
||||
- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动
|
||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||
- 后端默认监听 `PORT`,默认值 `3001`
|
||||
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
||||
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
|
||||
|
||||
日志:
|
||||
|
||||
|
||||
34
moviepilot
34
moviepilot
@@ -16,6 +16,7 @@ Bootstrap Commands:
|
||||
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
||||
moviepilot uninstall [--venv PATH] [--config-dir PATH]
|
||||
moviepilot update {backend|frontend|all} [OPTIONS]
|
||||
moviepilot startup {enable|disable|status} [--venv PATH] [--config-dir PATH]
|
||||
moviepilot agent [OPTIONS] MESSAGE...
|
||||
|
||||
Runtime Commands:
|
||||
@@ -30,6 +31,7 @@ Discovery Commands:
|
||||
moviepilot help install
|
||||
moviepilot help uninstall
|
||||
moviepilot help update
|
||||
moviepilot help startup
|
||||
moviepilot commands
|
||||
|
||||
Examples:
|
||||
@@ -39,6 +41,7 @@ Examples:
|
||||
moviepilot setup --wizard
|
||||
moviepilot uninstall
|
||||
moviepilot update all
|
||||
moviepilot startup enable
|
||||
moviepilot agent 帮我分析最近一次搜索失败
|
||||
moviepilot help config
|
||||
moviepilot config keys
|
||||
@@ -59,6 +62,9 @@ Bootstrap Commands
|
||||
update backend
|
||||
update frontend
|
||||
update all
|
||||
startup enable
|
||||
startup disable
|
||||
startup status
|
||||
agent
|
||||
|
||||
Runtime Commands
|
||||
@@ -185,6 +191,25 @@ Options:
|
||||
EOF
|
||||
}
|
||||
|
||||
show_startup_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
moviepilot startup enable [OPTIONS]
|
||||
moviepilot startup disable [OPTIONS]
|
||||
moviepilot startup status [OPTIONS]
|
||||
|
||||
Options:
|
||||
--venv PATH 虚拟环境目录,默认 ./venv
|
||||
--config-dir PATH 指定配置目录,默认使用当前安装配置
|
||||
-h, --help 显示帮助
|
||||
|
||||
说明:
|
||||
- macOS 使用 LaunchAgent
|
||||
- Linux 优先使用 systemd --user,不可用时回退到 XDG autostart
|
||||
- Windows 使用当前用户的 Startup 启动目录
|
||||
EOF
|
||||
}
|
||||
|
||||
show_agent_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
@@ -328,6 +353,10 @@ show_command_help() {
|
||||
show_update_help
|
||||
exit 0
|
||||
;;
|
||||
startup)
|
||||
show_startup_help
|
||||
exit 0
|
||||
;;
|
||||
commands)
|
||||
show_commands
|
||||
exit 0
|
||||
@@ -432,6 +461,11 @@ case "${1:-}" in
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@"
|
||||
;;
|
||||
startup)
|
||||
shift
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" startup "$@"
|
||||
;;
|
||||
agent)
|
||||
shift
|
||||
require_bootstrap_python
|
||||
|
||||
@@ -15,6 +15,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import textwrap
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
@@ -75,6 +76,183 @@ RUNTIME_PACKAGE = {
|
||||
"express-http-proxy": "^2.0.0",
|
||||
},
|
||||
}
|
||||
LOCAL_FRONTEND_SERVICE_SCRIPT = textwrap.dedent(
|
||||
"""
|
||||
const http = require('node:http')
|
||||
const path = require('node:path')
|
||||
const express = require('express')
|
||||
const proxy = require('express-http-proxy')
|
||||
|
||||
const app = express()
|
||||
const backendHost = process.env.MOVIEPILOT_BACKEND_HOST || '127.0.0.1'
|
||||
const backendPort = Number(process.env.PORT || 3001)
|
||||
const frontendPort = Number(process.env.NGINX_PORT || 3000)
|
||||
const backendHealthPath = '/api/v1/system/global?token=moviepilot'
|
||||
const backendHealthTimeoutMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_TIMEOUT_MS || 3000)
|
||||
const backendHealthIntervalMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_INTERVAL_MS || 15000)
|
||||
const backendMaxFailures = Math.max(
|
||||
Number(process.env.MOVIEPILOT_FRONTEND_MAX_FAILURES || 4),
|
||||
1
|
||||
)
|
||||
|
||||
function sleep (ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function checkBackendHealth () {
|
||||
return new Promise(resolve => {
|
||||
const request = http.request(
|
||||
{
|
||||
host: backendHost,
|
||||
port: backendPort,
|
||||
path: backendHealthPath,
|
||||
method: 'GET',
|
||||
timeout: backendHealthTimeoutMs
|
||||
},
|
||||
response => {
|
||||
let body = ''
|
||||
response.setEncoding('utf8')
|
||||
response.on('data', chunk => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => {
|
||||
if (response.statusCode !== 200) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body)
|
||||
resolve(payload?.success !== false)
|
||||
} catch (error) {
|
||||
// 健康检查接口只要返回 200,就允许继续提供前端服务。
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error('backend health check timeout'))
|
||||
})
|
||||
request.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForBackendReady () {
|
||||
for (let attempt = 1; attempt <= backendMaxFailures; attempt += 1) {
|
||||
if (await checkBackendHealth()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (attempt < backendMaxFailures) {
|
||||
await sleep(1000)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function startBackendWatchdog (server) {
|
||||
let consecutiveFailures = 0
|
||||
let checking = false
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
if (checking) {
|
||||
return
|
||||
}
|
||||
|
||||
checking = true
|
||||
try {
|
||||
const healthy = await checkBackendHealth()
|
||||
if (healthy) {
|
||||
consecutiveFailures = 0
|
||||
return
|
||||
}
|
||||
|
||||
consecutiveFailures += 1
|
||||
console.warn(
|
||||
`Backend health check failed (${consecutiveFailures}/${backendMaxFailures})`
|
||||
)
|
||||
|
||||
if (consecutiveFailures < backendMaxFailures) {
|
||||
return
|
||||
}
|
||||
|
||||
clearInterval(timer)
|
||||
console.error('Backend is unavailable, stopping frontend service')
|
||||
server.close(() => process.exit(1))
|
||||
setTimeout(() => process.exit(1), 1000).unref()
|
||||
} finally {
|
||||
checking = false
|
||||
}
|
||||
}, backendHealthIntervalMs)
|
||||
|
||||
timer.unref()
|
||||
|
||||
const shutdown = signal => {
|
||||
clearInterval(timer)
|
||||
console.log(`Received ${signal}, shutting down frontend service`)
|
||||
server.close(() => process.exit(0))
|
||||
setTimeout(() => process.exit(0), 1000).unref()
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
}
|
||||
|
||||
// 静态文件服务目录
|
||||
app.use(express.static(__dirname))
|
||||
|
||||
// 配置代理中间件将请求转发给后端 API。
|
||||
app.use(
|
||||
'/api',
|
||||
proxy(`${backendHost}:${backendPort}`, {
|
||||
proxyReqPathResolver: req => `/api${req.url}`
|
||||
})
|
||||
)
|
||||
|
||||
// 配置代理中间件将 CookieCloud 请求转发给后端 API。
|
||||
app.use(
|
||||
'/cookiecloud',
|
||||
proxy(`${backendHost}:${backendPort}`, {
|
||||
proxyReqPathResolver: req => `/cookiecloud${req.url}`
|
||||
})
|
||||
)
|
||||
|
||||
// 处理根路径的请求。
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'index.html'))
|
||||
})
|
||||
|
||||
// 处理所有其他请求,重定向到前端入口文件。
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'index.html'))
|
||||
})
|
||||
|
||||
async function bootstrap () {
|
||||
// 前端本地代理不再允许单独存活,避免设备重启后只剩前端进程。
|
||||
const backendReady = await waitForBackendReady()
|
||||
if (!backendReady) {
|
||||
console.error('Backend is unavailable, skip starting frontend service')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const server = app.listen(frontendPort, () => {
|
||||
console.log(`Server is running on port ${frontendPort}`)
|
||||
})
|
||||
|
||||
startBackendWatchdog(server)
|
||||
}
|
||||
|
||||
bootstrap().catch(error => {
|
||||
console.error(`Failed to start frontend service: ${error?.message || error}`)
|
||||
process.exit(1)
|
||||
})
|
||||
"""
|
||||
).lstrip()
|
||||
NOTIFICATION_SWITCH_TYPES = [
|
||||
"资源下载",
|
||||
"整理入库",
|
||||
@@ -88,6 +266,15 @@ NOTIFICATION_SWITCH_TYPES = [
|
||||
]
|
||||
UNINSTALL_CONFIRM_TEXT = "UNINSTALL"
|
||||
RESOURCE_FILE_PATTERNS = ("sites*", "user.sites*.bin")
|
||||
AUTOSTART_ENV_KEY = "MOVIEPILOT_AUTO_START"
|
||||
AUTOSTART_RUNTIME_DIR = RUNTIME_DIR / "startup"
|
||||
AUTOSTART_UNIX_LAUNCHER = AUTOSTART_RUNTIME_DIR / "moviepilot-start.sh"
|
||||
AUTOSTART_WINDOWS_LAUNCHER = AUTOSTART_RUNTIME_DIR / "moviepilot-start.cmd"
|
||||
AUTOSTART_TIMEOUT = 120
|
||||
MACOS_LAUNCH_AGENT_LABEL = "org.moviepilot.localcli"
|
||||
LINUX_SYSTEMD_UNIT_NAME = "moviepilot-autostart.service"
|
||||
LINUX_XDG_AUTOSTART_FILENAME = "moviepilot.desktop"
|
||||
WINDOWS_STARTUP_FILENAME = "MoviePilot Startup.cmd"
|
||||
|
||||
|
||||
def _default_config_dir() -> Path:
|
||||
@@ -490,6 +677,16 @@ def _frontend_runtime_ready(frontend_version: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _write_local_frontend_service_script(target_dir: Path) -> None:
|
||||
"""
|
||||
覆盖前端 release 自带的 service.js,统一使用本地 CLI 的受控代理脚本。
|
||||
"""
|
||||
(target_dir / "service.js").write_text(
|
||||
LOCAL_FRONTEND_SERVICE_SCRIPT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _node_platform() -> tuple[str, str]:
|
||||
system_name = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
@@ -562,6 +759,7 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]
|
||||
node_bin = install_node_runtime(node_version)
|
||||
|
||||
if _frontend_runtime_ready(version_tag):
|
||||
_write_local_frontend_service_script(PUBLIC_DIR)
|
||||
print_step(f"前端发布包已是最新版本:{version_tag}")
|
||||
return {"version": version_tag, "node": str(node_bin)}
|
||||
|
||||
@@ -578,6 +776,8 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]
|
||||
_remove_path(PUBLIC_DIR)
|
||||
shutil.move(str(dist_dir), str(PUBLIC_DIR))
|
||||
|
||||
_write_local_frontend_service_script(PUBLIC_DIR)
|
||||
|
||||
runtime_package = dict(RUNTIME_PACKAGE)
|
||||
runtime_package["version"] = version_tag
|
||||
(PUBLIC_DIR / "package.json").write_text(
|
||||
@@ -1431,6 +1631,23 @@ def _collect_site_auth_config(
|
||||
}
|
||||
|
||||
|
||||
def _collect_autostart_config() -> dict[str, Any]:
|
||||
print_step("开机自启配置")
|
||||
current_status = _autostart_status()
|
||||
default_enabled = bool(current_status.get("enabled")) or _env_bool(
|
||||
AUTOSTART_ENV_KEY, False
|
||||
)
|
||||
if current_status.get("enabled"):
|
||||
print(
|
||||
f"当前已检测到开机自启:{current_status.get('label') or _startup_platform_name()}"
|
||||
)
|
||||
else:
|
||||
print(f"当前系统将使用:{_startup_platform_name()}")
|
||||
|
||||
enabled = _prompt_yes_no("是否设置开机自启", default=default_enabled)
|
||||
return {"enabled": enabled}
|
||||
|
||||
|
||||
def run_setup_wizard(
|
||||
force_token: bool,
|
||||
runtime_python: Optional[Path] = None,
|
||||
@@ -1492,6 +1709,7 @@ def run_setup_wizard(
|
||||
"mediaserver": _collect_media_server_config(),
|
||||
"notification": _collect_notification_config(),
|
||||
"site_auth": _collect_site_auth_config(runtime_python=runtime_python),
|
||||
"autostart": _collect_autostart_config(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1782,6 +2000,37 @@ def apply_local_system_config(
|
||||
)
|
||||
|
||||
|
||||
def _apply_autostart_choice(
|
||||
autostart_payload: Optional[dict[str, Any]],
|
||||
*,
|
||||
config_dir: Path,
|
||||
runtime_python: Optional[Path],
|
||||
venv_dir: Optional[Path],
|
||||
) -> None:
|
||||
if not isinstance(autostart_payload, dict):
|
||||
return
|
||||
|
||||
if autostart_payload.get("enabled"):
|
||||
result = enable_autostart(
|
||||
config_dir=config_dir,
|
||||
runtime_python=runtime_python,
|
||||
venv_dir=venv_dir,
|
||||
)
|
||||
print_step(f"已启用开机自启:{result.get('method')}")
|
||||
if result.get("artifact"):
|
||||
print(f" 注册文件:{result['artifact']}")
|
||||
if result.get("note"):
|
||||
print(f" 说明:{result['note']}")
|
||||
return
|
||||
|
||||
result = disable_autostart()
|
||||
removed_paths = result.get("removed_paths") or []
|
||||
if removed_paths:
|
||||
print_step("已取消开机自启注册")
|
||||
else:
|
||||
print_step("当前未配置开机自启,无需取消")
|
||||
|
||||
|
||||
def init_local(
|
||||
*,
|
||||
resources_repo: Optional[Path],
|
||||
@@ -1793,6 +2042,7 @@ def init_local(
|
||||
superuser: Optional[str],
|
||||
superuser_password: Optional[str],
|
||||
runtime_python: Optional[Path] = None,
|
||||
venv_dir: Optional[Path] = None,
|
||||
) -> None:
|
||||
ensure_local_dirs()
|
||||
|
||||
@@ -1842,6 +2092,17 @@ def init_local(
|
||||
elif direct_env_settings:
|
||||
sync_superuser_account(runtime_python=runtime_python)
|
||||
|
||||
if wizard_payload:
|
||||
try:
|
||||
_apply_autostart_choice(
|
||||
wizard_payload.get("autostart"),
|
||||
config_dir=CONFIG_DIR,
|
||||
runtime_python=runtime_python,
|
||||
venv_dir=venv_dir,
|
||||
)
|
||||
except Exception as exc:
|
||||
print_step(f"开机自启配置未完成:{exc}")
|
||||
|
||||
|
||||
def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
|
||||
ensure_supported_python(python_bin)
|
||||
@@ -1869,6 +2130,496 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
|
||||
return venv_python
|
||||
|
||||
|
||||
def _startup_platform_name() -> str:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS LaunchAgent"
|
||||
if system == "Linux":
|
||||
return "Linux systemd/XDG"
|
||||
if system == "Windows":
|
||||
return "Windows Startup"
|
||||
return system or "unknown"
|
||||
|
||||
|
||||
def _runtime_python_candidates(
|
||||
runtime_python: Optional[Path], venv_dir: Optional[Path]
|
||||
) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
raw_candidates = [
|
||||
runtime_python,
|
||||
get_venv_python((venv_dir or (ROOT / "venv")).expanduser().resolve()),
|
||||
Path(sys.executable) if sys.executable else None,
|
||||
]
|
||||
for candidate in raw_candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
resolved = Path(candidate).expanduser().resolve()
|
||||
key = str(resolved)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
candidates.append(resolved)
|
||||
return candidates
|
||||
|
||||
|
||||
def _can_run_moviepilot_cli(python_bin: Path) -> bool:
|
||||
if not python_bin.exists():
|
||||
return False
|
||||
|
||||
result = subprocess.run(
|
||||
[str(python_bin), "-m", "app.cli", "--help"],
|
||||
cwd=str(ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _resolve_runtime_python_for_startup(
|
||||
runtime_python: Optional[Path], venv_dir: Optional[Path]
|
||||
) -> Path:
|
||||
for candidate in _runtime_python_candidates(runtime_python, venv_dir):
|
||||
if _can_run_moviepilot_cli(candidate):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
"未找到可用于启动 MoviePilot 的 Python 运行环境,请先执行 moviepilot install deps 或 moviepilot setup"
|
||||
)
|
||||
|
||||
|
||||
def _linux_user_systemd_dir() -> Path:
|
||||
return (
|
||||
Path(os.getenv("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
/ "systemd"
|
||||
/ "user"
|
||||
)
|
||||
|
||||
|
||||
def _linux_xdg_autostart_dir() -> Path:
|
||||
return Path(os.getenv("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "autostart"
|
||||
|
||||
|
||||
def _macos_launch_agent_path() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{MACOS_LAUNCH_AGENT_LABEL}.plist"
|
||||
|
||||
|
||||
def _linux_systemd_unit_path() -> Path:
|
||||
return _linux_user_systemd_dir() / LINUX_SYSTEMD_UNIT_NAME
|
||||
|
||||
|
||||
def _linux_xdg_autostart_path() -> Path:
|
||||
return _linux_xdg_autostart_dir() / LINUX_XDG_AUTOSTART_FILENAME
|
||||
|
||||
|
||||
def _windows_startup_dir() -> Path:
|
||||
appdata = os.getenv("APPDATA")
|
||||
if appdata:
|
||||
return (
|
||||
Path(appdata)
|
||||
/ "Microsoft"
|
||||
/ "Windows"
|
||||
/ "Start Menu"
|
||||
/ "Programs"
|
||||
/ "Startup"
|
||||
)
|
||||
return (
|
||||
Path.home()
|
||||
/ "AppData"
|
||||
/ "Roaming"
|
||||
/ "Microsoft"
|
||||
/ "Windows"
|
||||
/ "Start Menu"
|
||||
/ "Programs"
|
||||
/ "Startup"
|
||||
)
|
||||
|
||||
|
||||
def _windows_startup_path() -> Path:
|
||||
return _windows_startup_dir() / WINDOWS_STARTUP_FILENAME
|
||||
|
||||
|
||||
def _launcher_paths_for_platform(system_name: Optional[str] = None) -> list[Path]:
|
||||
system_name = system_name or platform.system()
|
||||
if system_name == "Windows":
|
||||
return [AUTOSTART_WINDOWS_LAUNCHER]
|
||||
return [AUTOSTART_UNIX_LAUNCHER]
|
||||
|
||||
|
||||
def _cleanup_startup_launchers(system_name: Optional[str] = None) -> None:
|
||||
for path in _launcher_paths_for_platform(system_name):
|
||||
if path.exists():
|
||||
_remove_path(path)
|
||||
|
||||
if AUTOSTART_RUNTIME_DIR.exists() and not any(AUTOSTART_RUNTIME_DIR.iterdir()):
|
||||
AUTOSTART_RUNTIME_DIR.rmdir()
|
||||
|
||||
|
||||
def _write_unix_startup_launcher(config_dir: Path, python_bin: Path) -> Path:
|
||||
AUTOSTART_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
launcher_content = textwrap.dedent(
|
||||
f"""\
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export CONFIG_DIR={shlex.quote(str(config_dir))}
|
||||
cd {shlex.quote(str(ROOT))}
|
||||
exec {shlex.quote(str(python_bin))} -m app.cli start --timeout {AUTOSTART_TIMEOUT}
|
||||
"""
|
||||
)
|
||||
AUTOSTART_UNIX_LAUNCHER.write_text(launcher_content, encoding="utf-8")
|
||||
AUTOSTART_UNIX_LAUNCHER.chmod(0o755)
|
||||
return AUTOSTART_UNIX_LAUNCHER
|
||||
|
||||
|
||||
def _write_windows_startup_launcher(config_dir: Path, python_bin: Path) -> Path:
|
||||
AUTOSTART_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
launcher_content = textwrap.dedent(
|
||||
f"""\
|
||||
@echo off
|
||||
setlocal
|
||||
set "CONFIG_DIR={config_dir}"
|
||||
cd /d "{ROOT}"
|
||||
"{python_bin}" -m app.cli start --timeout {AUTOSTART_TIMEOUT}
|
||||
endlocal
|
||||
"""
|
||||
)
|
||||
AUTOSTART_WINDOWS_LAUNCHER.write_text(launcher_content, encoding="utf-8")
|
||||
return AUTOSTART_WINDOWS_LAUNCHER
|
||||
|
||||
|
||||
def _double_quote(value: Any) -> str:
|
||||
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _run_optional_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=str(ROOT),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _last_command_line(result: subprocess.CompletedProcess[str]) -> str:
|
||||
lines = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
||||
return lines[-1] if lines else "命令未返回更多信息"
|
||||
|
||||
|
||||
def _linux_linger_enabled() -> Optional[bool]:
|
||||
loginctl_bin = shutil.which("loginctl")
|
||||
if not loginctl_bin:
|
||||
return None
|
||||
|
||||
result = _run_optional_command(
|
||||
[loginctl_bin, "show-user", getpass.getuser(), "-p", "Linger", "--value"]
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
value = (result.stdout or "").strip().lower()
|
||||
if value in {"yes", "no"}:
|
||||
return value == "yes"
|
||||
return None
|
||||
|
||||
|
||||
def _autostart_status() -> dict[str, Any]:
|
||||
system_name = platform.system()
|
||||
if system_name == "Darwin":
|
||||
artifact = _macos_launch_agent_path()
|
||||
return {
|
||||
"enabled": artifact.exists(),
|
||||
"method": "launchagent",
|
||||
"label": "LaunchAgent",
|
||||
"artifact": artifact,
|
||||
}
|
||||
if system_name == "Linux":
|
||||
systemd_unit = _linux_systemd_unit_path()
|
||||
if systemd_unit.exists():
|
||||
return {
|
||||
"enabled": True,
|
||||
"method": "systemd-user",
|
||||
"label": "systemd --user",
|
||||
"artifact": systemd_unit,
|
||||
"linger_enabled": _linux_linger_enabled(),
|
||||
}
|
||||
desktop_file = _linux_xdg_autostart_path()
|
||||
return {
|
||||
"enabled": desktop_file.exists(),
|
||||
"method": "xdg-autostart" if desktop_file.exists() else "none",
|
||||
"label": "XDG autostart" if desktop_file.exists() else "not-configured",
|
||||
"artifact": desktop_file if desktop_file.exists() else None,
|
||||
}
|
||||
if system_name == "Windows":
|
||||
artifact = _windows_startup_path()
|
||||
return {
|
||||
"enabled": artifact.exists(),
|
||||
"method": "startup-folder",
|
||||
"label": "Startup Folder",
|
||||
"artifact": artifact,
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": False,
|
||||
"method": "unsupported",
|
||||
"label": _startup_platform_name(),
|
||||
"artifact": None,
|
||||
}
|
||||
|
||||
|
||||
def _enable_autostart_macos(config_dir: Path, python_bin: Path) -> dict[str, Any]:
|
||||
launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin)
|
||||
agent_path = _macos_launch_agent_path()
|
||||
agent_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
plist_content = textwrap.dedent(
|
||||
f"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{MACOS_LAUNCH_AGENT_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>{launcher}</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{ROOT}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{LOG_DIR / "moviepilot.launchagent.stdout.log"}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{LOG_DIR / "moviepilot.launchagent.stderr.log"}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
)
|
||||
agent_path.write_text(plist_content, encoding="utf-8")
|
||||
|
||||
uid = str(os.getuid())
|
||||
_run_optional_command(["launchctl", "bootout", f"gui/{uid}", str(agent_path)])
|
||||
bootstrap_result = _run_optional_command(
|
||||
["launchctl", "bootstrap", f"gui/{uid}", str(agent_path)]
|
||||
)
|
||||
if bootstrap_result.returncode != 0:
|
||||
note = _last_command_line(bootstrap_result)
|
||||
else:
|
||||
enable_result = _run_optional_command(
|
||||
["launchctl", "enable", f"gui/{uid}/{MACOS_LAUNCH_AGENT_LABEL}"]
|
||||
)
|
||||
note = (
|
||||
_last_command_line(enable_result)
|
||||
if enable_result.returncode != 0
|
||||
else "已加载到当前登录会话"
|
||||
)
|
||||
|
||||
write_env_value(AUTOSTART_ENV_KEY, "true")
|
||||
return {
|
||||
"method": "LaunchAgent",
|
||||
"artifact": agent_path,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
|
||||
def _enable_autostart_linux_systemd(
|
||||
config_dir: Path, python_bin: Path
|
||||
) -> Optional[dict[str, Any]]:
|
||||
systemctl_bin = shutil.which("systemctl")
|
||||
if not systemctl_bin:
|
||||
return None
|
||||
|
||||
launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin)
|
||||
unit_path = _linux_systemd_unit_path()
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
unit_content = textwrap.dedent(
|
||||
f"""\
|
||||
[Unit]
|
||||
Description=MoviePilot local autostart
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory={ROOT}
|
||||
ExecStart=/bin/bash {_double_quote(launcher)}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
)
|
||||
unit_path.write_text(unit_content, encoding="utf-8")
|
||||
|
||||
_run_optional_command([systemctl_bin, "--user", "daemon-reload"])
|
||||
enable_result = _run_optional_command(
|
||||
[systemctl_bin, "--user", "enable", LINUX_SYSTEMD_UNIT_NAME]
|
||||
)
|
||||
if enable_result.returncode != 0:
|
||||
_remove_path(unit_path)
|
||||
_run_optional_command([systemctl_bin, "--user", "daemon-reload"])
|
||||
return None
|
||||
|
||||
start_result = _run_optional_command(
|
||||
[systemctl_bin, "--user", "start", LINUX_SYSTEMD_UNIT_NAME]
|
||||
)
|
||||
desktop_path = _linux_xdg_autostart_path()
|
||||
if desktop_path.exists():
|
||||
_remove_path(desktop_path)
|
||||
note = (
|
||||
_last_command_line(start_result)
|
||||
if start_result.returncode != 0
|
||||
else "已注册 systemd --user 并尝试在当前会话执行一次"
|
||||
)
|
||||
linger_enabled = _linux_linger_enabled()
|
||||
if linger_enabled is False:
|
||||
note += ";如需无人登录时随系统启动,请手动执行 sudo loginctl enable-linger $USER"
|
||||
|
||||
write_env_value(AUTOSTART_ENV_KEY, "true")
|
||||
return {
|
||||
"method": "systemd --user",
|
||||
"artifact": unit_path,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
|
||||
def _enable_autostart_linux_xdg(config_dir: Path, python_bin: Path) -> dict[str, Any]:
|
||||
launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin)
|
||||
desktop_path = _linux_xdg_autostart_path()
|
||||
desktop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
unit_path = _linux_systemd_unit_path()
|
||||
if unit_path.exists():
|
||||
_remove_path(unit_path)
|
||||
systemctl_bin = shutil.which("systemctl")
|
||||
if systemctl_bin:
|
||||
_run_optional_command([systemctl_bin, "--user", "daemon-reload"])
|
||||
desktop_content = textwrap.dedent(
|
||||
f"""\
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=MoviePilot
|
||||
Comment=Start MoviePilot on login
|
||||
Exec=/bin/bash {_double_quote(launcher)}
|
||||
Path={ROOT}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
"""
|
||||
)
|
||||
desktop_path.write_text(desktop_content, encoding="utf-8")
|
||||
write_env_value(AUTOSTART_ENV_KEY, "true")
|
||||
return {
|
||||
"method": "XDG autostart",
|
||||
"artifact": desktop_path,
|
||||
"note": "当前环境未启用 systemd --user,已回退为图形会话登录自启动",
|
||||
}
|
||||
|
||||
|
||||
def _enable_autostart_windows(config_dir: Path, python_bin: Path) -> dict[str, Any]:
|
||||
launcher = _write_windows_startup_launcher(config_dir=config_dir, python_bin=python_bin)
|
||||
startup_path = _windows_startup_path()
|
||||
startup_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
startup_content = textwrap.dedent(
|
||||
f"""\
|
||||
@echo off
|
||||
call "{launcher}"
|
||||
"""
|
||||
)
|
||||
startup_path.write_text(startup_content, encoding="utf-8")
|
||||
write_env_value(AUTOSTART_ENV_KEY, "true")
|
||||
return {
|
||||
"method": "Startup Folder",
|
||||
"artifact": startup_path,
|
||||
"note": "将在当前用户登录 Windows 后自动启动",
|
||||
}
|
||||
|
||||
|
||||
def enable_autostart(
|
||||
*, config_dir: Path, runtime_python: Optional[Path], venv_dir: Optional[Path]
|
||||
) -> dict[str, Any]:
|
||||
config_dir = config_dir.expanduser().resolve()
|
||||
python_bin = _resolve_runtime_python_for_startup(runtime_python, venv_dir)
|
||||
system_name = platform.system()
|
||||
|
||||
if system_name == "Darwin":
|
||||
return _enable_autostart_macos(config_dir=config_dir, python_bin=python_bin)
|
||||
if system_name == "Linux":
|
||||
return _enable_autostart_linux_systemd(
|
||||
config_dir=config_dir, python_bin=python_bin
|
||||
) or _enable_autostart_linux_xdg(config_dir=config_dir, python_bin=python_bin)
|
||||
if system_name == "Windows":
|
||||
return _enable_autostart_windows(config_dir=config_dir, python_bin=python_bin)
|
||||
|
||||
raise RuntimeError(f"当前系统暂不支持自动注册开机自启:{platform.system()}")
|
||||
|
||||
|
||||
def disable_autostart() -> dict[str, Any]:
|
||||
system_name = platform.system()
|
||||
removed_paths: list[Path] = []
|
||||
|
||||
if system_name == "Darwin":
|
||||
agent_path = _macos_launch_agent_path()
|
||||
uid = str(os.getuid())
|
||||
_run_optional_command(["launchctl", "bootout", f"gui/{uid}", str(agent_path)])
|
||||
if agent_path.exists():
|
||||
_remove_path(agent_path)
|
||||
removed_paths.append(agent_path)
|
||||
_cleanup_startup_launchers(system_name)
|
||||
elif system_name == "Linux":
|
||||
systemctl_bin = shutil.which("systemctl")
|
||||
unit_path = _linux_systemd_unit_path()
|
||||
desktop_path = _linux_xdg_autostart_path()
|
||||
if systemctl_bin:
|
||||
_run_optional_command(
|
||||
[systemctl_bin, "--user", "disable", LINUX_SYSTEMD_UNIT_NAME]
|
||||
)
|
||||
_run_optional_command([systemctl_bin, "--user", "daemon-reload"])
|
||||
for path in (unit_path, desktop_path):
|
||||
if path.exists():
|
||||
_remove_path(path)
|
||||
removed_paths.append(path)
|
||||
_cleanup_startup_launchers(system_name)
|
||||
elif system_name == "Windows":
|
||||
startup_path = _windows_startup_path()
|
||||
for path in (startup_path, AUTOSTART_WINDOWS_LAUNCHER):
|
||||
if path.exists():
|
||||
_remove_path(path)
|
||||
removed_paths.append(path)
|
||||
_cleanup_startup_launchers(system_name)
|
||||
else:
|
||||
raise RuntimeError(f"当前系统暂不支持自动取消开机自启:{platform.system()}")
|
||||
|
||||
write_env_value(AUTOSTART_ENV_KEY, "false")
|
||||
return {"removed_paths": removed_paths}
|
||||
|
||||
|
||||
def print_autostart_status() -> None:
|
||||
status = _autostart_status()
|
||||
if not status.get("enabled"):
|
||||
print_step(f"当前未启用开机自启({_startup_platform_name()})")
|
||||
return
|
||||
|
||||
print_step(
|
||||
f"当前已启用开机自启:{status.get('label') or _startup_platform_name()}"
|
||||
)
|
||||
artifact = status.get("artifact")
|
||||
if artifact:
|
||||
print(f" 注册文件:{artifact}")
|
||||
linger_enabled = status.get("linger_enabled")
|
||||
if linger_enabled is False:
|
||||
print(
|
||||
" 说明:当前为 systemd --user 模式,通常会在用户登录后启动;如需无人登录即启动,请手动启用 linger。"
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_file(path: Path) -> Optional[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -2074,6 +2825,7 @@ def uninstall_local(
|
||||
for path in cli_links
|
||||
if path.is_symlink() and path.exists() and path.resolve() == script_path
|
||||
]
|
||||
autostart_status = _autostart_status()
|
||||
|
||||
delete_config = _prompt_yes_no(
|
||||
f"是否同时删除配置目录 {config_dir}", default=False
|
||||
@@ -2091,6 +2843,12 @@ def uninstall_local(
|
||||
print(f" {path}")
|
||||
else:
|
||||
print(" - 未检测到指向当前仓库的全局 CLI 软链接")
|
||||
if autostart_status.get("enabled"):
|
||||
print(
|
||||
f" - 取消开机自启:{autostart_status.get('label') or _startup_platform_name()}"
|
||||
)
|
||||
else:
|
||||
print(" - 当前未配置开机自启")
|
||||
|
||||
if delete_config:
|
||||
print(f" - 删除配置目录:{config_dir}")
|
||||
@@ -2112,6 +2870,8 @@ def uninstall_local(
|
||||
return {"cancelled": True}
|
||||
|
||||
_stop_managed_services(venv_dir=venv_dir)
|
||||
if autostart_status.get("enabled"):
|
||||
disable_autostart()
|
||||
|
||||
removed_paths: list[Path] = []
|
||||
removed_paths.extend(
|
||||
@@ -2210,6 +2970,44 @@ def update_backend(
|
||||
return venv_python
|
||||
|
||||
|
||||
def handle_startup_command(
|
||||
*,
|
||||
action: str,
|
||||
config_dir: Path,
|
||||
runtime_python: Optional[Path],
|
||||
venv_dir: Optional[Path],
|
||||
) -> None:
|
||||
if action == "status":
|
||||
print_autostart_status()
|
||||
return
|
||||
|
||||
if action == "enable":
|
||||
result = enable_autostart(
|
||||
config_dir=config_dir,
|
||||
runtime_python=runtime_python,
|
||||
venv_dir=venv_dir,
|
||||
)
|
||||
print_step(f"已启用开机自启:{result.get('method')}")
|
||||
if result.get("artifact"):
|
||||
print(f"注册文件:{result['artifact']}")
|
||||
if result.get("note"):
|
||||
print(f"说明:{result['note']}")
|
||||
return
|
||||
|
||||
if action == "disable":
|
||||
result = disable_autostart()
|
||||
removed_paths = result.get("removed_paths") or []
|
||||
if removed_paths:
|
||||
print_step("已取消开机自启注册")
|
||||
for path in removed_paths:
|
||||
print(f"已移除:{path}")
|
||||
else:
|
||||
print_step("当前未配置开机自启,无需取消")
|
||||
return
|
||||
|
||||
raise RuntimeError(f"未知的 startup 动作:{action}")
|
||||
|
||||
|
||||
def run_agent_request(
|
||||
*, message: str, session_id: Optional[str], new_session: bool, user_id: str
|
||||
) -> dict[str, str]:
|
||||
@@ -2406,6 +3204,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
||||
)
|
||||
|
||||
startup_parser = subparsers.add_parser(
|
||||
"startup", help="注册、取消或查看本地开机自启"
|
||||
)
|
||||
startup_parser.add_argument(
|
||||
"action", choices=["enable", "disable", "status"], help="开机自启动作"
|
||||
)
|
||||
startup_parser.add_argument(
|
||||
"--venv", default=str(ROOT / "venv"), help="虚拟环境目录"
|
||||
)
|
||||
startup_parser.add_argument(
|
||||
"--config-dir", help="配置目录,默认使用当前安装配置"
|
||||
)
|
||||
|
||||
apply_config_parser = subparsers.add_parser("apply-config", help=argparse.SUPPRESS)
|
||||
apply_config_parser.add_argument(
|
||||
"--config-json-file", required=True, help=argparse.SUPPRESS
|
||||
@@ -2490,6 +3301,7 @@ def main() -> int:
|
||||
superuser=args.superuser,
|
||||
superuser_password=args.superuser_password,
|
||||
runtime_python=None,
|
||||
venv_dir=ROOT / "venv",
|
||||
)
|
||||
print_step("初始化完成")
|
||||
print_step(f"当前配置目录:{config_dir}")
|
||||
@@ -2525,6 +3337,7 @@ def main() -> int:
|
||||
superuser=args.superuser,
|
||||
superuser_password=args.superuser_password,
|
||||
runtime_python=venv_python,
|
||||
venv_dir=Path(args.venv),
|
||||
)
|
||||
print_step(f"本地环境已完成安装与初始化:{venv_python}")
|
||||
print_step(f"当前配置目录:{config_dir}")
|
||||
@@ -2572,6 +3385,20 @@ def main() -> int:
|
||||
print_step(f"更新完成,当前配置目录:{config_dir}")
|
||||
return 0
|
||||
|
||||
if args.command == "startup":
|
||||
runtime_python = None
|
||||
if args.action == "enable":
|
||||
runtime_python = _resolve_runtime_python_for_startup(
|
||||
None, Path(args.venv)
|
||||
)
|
||||
handle_startup_command(
|
||||
action=args.action,
|
||||
config_dir=config_dir,
|
||||
runtime_python=runtime_python,
|
||||
venv_dir=Path(args.venv),
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "apply-config":
|
||||
payload = json.loads(
|
||||
Path(args.config_json_file).read_text(encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user