mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: improve local CLI startup management
This commit is contained in:
+208
-9
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -9,7 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
|
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import ProxyHandler, Request, build_opener, urlopen
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import psutil
|
import psutil
|
||||||
@@ -28,7 +29,11 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
|
|||||||
HEALTH_PATH = "/api/v1/system/global"
|
HEALTH_PATH = "/api/v1/system/global"
|
||||||
HEALTH_TOKEN = "moviepilot"
|
HEALTH_TOKEN = "moviepilot"
|
||||||
FRONTEND_HEALTH_PATH = "/version.txt"
|
FRONTEND_HEALTH_PATH = "/version.txt"
|
||||||
|
BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
|
||||||
|
FRONTEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases"
|
||||||
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
||||||
|
MANAGED_ACTIVE_STATES = {"running", "starting"}
|
||||||
|
AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"}
|
||||||
MASKED_FIELDS = {
|
MASKED_FIELDS = {
|
||||||
"API_TOKEN",
|
"API_TOKEN",
|
||||||
"DB_POSTGRESQL_PASSWORD",
|
"DB_POSTGRESQL_PASSWORD",
|
||||||
@@ -199,6 +204,173 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float =
|
|||||||
return False, None
|
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]]]:
|
def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
|
||||||
runtime = _backend_runtime()
|
runtime = _backend_runtime()
|
||||||
process = _get_process(runtime)
|
process = _get_process(runtime)
|
||||||
@@ -431,18 +603,27 @@ def _ensure_local_api_token() -> bool:
|
|||||||
return result is True
|
return result is True
|
||||||
|
|
||||||
|
|
||||||
def _spawn_process(command: list[str], *, cwd: Path, log_file: Path, env: Optional[Dict[str, str]] = None) -> subprocess.Popen:
|
def _spawn_process(
|
||||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
command: list[str],
|
||||||
log_handle = log_file.open("a", encoding="utf-8")
|
*,
|
||||||
|
cwd: Path,
|
||||||
|
log_file: Optional[Path],
|
||||||
|
env: Optional[Dict[str, str]] = None,
|
||||||
|
) -> subprocess.Popen:
|
||||||
kwargs: Dict[str, Any] = {
|
kwargs: Dict[str, Any] = {
|
||||||
"cwd": str(cwd),
|
"cwd": str(cwd),
|
||||||
"stdout": log_handle,
|
|
||||||
"stderr": subprocess.STDOUT,
|
|
||||||
"stdin": subprocess.DEVNULL,
|
"stdin": subprocess.DEVNULL,
|
||||||
"close_fds": True,
|
"close_fds": True,
|
||||||
"env": env or os.environ.copy(),
|
"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":
|
if os.name == "nt":
|
||||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||||
else:
|
else:
|
||||||
@@ -454,8 +635,19 @@ def _spawn_backend_process() -> subprocess.Popen:
|
|||||||
return _spawn_process(
|
return _spawn_process(
|
||||||
[sys.executable, "-m", "app.main"],
|
[sys.executable, "-m", "app.main"],
|
||||||
cwd=_repo_root(),
|
cwd=_repo_root(),
|
||||||
log_file=BACKEND_STDIO_LOG_FILE,
|
log_file=None,
|
||||||
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
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="等待后端与前端就绪的秒数")
|
@click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数")
|
||||||
def start(timeout: int) -> None:
|
def start(timeout: int) -> None:
|
||||||
"""后台启动本地 MoviePilot 前后端服务"""
|
"""后台启动本地 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_result = _start_backend_service(timeout=timeout)
|
||||||
backend_runtime = backend_result["runtime"]
|
backend_runtime = backend_result["runtime"]
|
||||||
try:
|
try:
|
||||||
@@ -699,6 +897,7 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
|
|||||||
"""重启本地 MoviePilot 前后端服务"""
|
"""重启本地 MoviePilot 前后端服务"""
|
||||||
_stop_frontend_service(timeout=stop_timeout, force=force)
|
_stop_frontend_service(timeout=stop_timeout, force=force)
|
||||||
_stop_backend_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)
|
backend_result = _start_backend_service(timeout=start_timeout)
|
||||||
frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]))
|
frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]))
|
||||||
click.echo("MoviePilot 已重启")
|
click.echo("MoviePilot 已重启")
|
||||||
|
|||||||
+7
-5
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -407,11 +408,12 @@ class LoggerManager:
|
|||||||
for handler in _logger.handlers:
|
for handler in _logger.handlers:
|
||||||
_logger.removeHandler(handler)
|
_logger.removeHandler(handler)
|
||||||
|
|
||||||
# 只设置终端日志(文件日志由 NonBlockingFileHandler 处理)
|
# 本地 CLI 已经有独立的 stdio 滚动日志时,不再把业务日志重复打一份到控制台。
|
||||||
console_handler = logging.StreamHandler()
|
if os.getenv("MOVIEPILOT_DISABLE_CONSOLE_LOG") != "1":
|
||||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(console_formatter)
|
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||||
_logger.addHandler(console_handler)
|
console_handler.setFormatter(console_formatter)
|
||||||
|
_logger.addHandler(console_handler)
|
||||||
|
|
||||||
# 禁止向父级log传递
|
# 禁止向父级log传递
|
||||||
_logger.propagate = False
|
_logger.propagate = False
|
||||||
|
|||||||
+15
-2
@@ -4,19 +4,32 @@ import setproctitle
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn as uvicorn
|
import uvicorn as uvicorn
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from uvicorn import Config
|
from uvicorn import Config
|
||||||
|
|
||||||
from app.factory import app
|
from app.utils.stdio import configure_rotating_stdio
|
||||||
from app.utils.system import SystemUtils
|
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.stdout = open(os.devnull, 'w')
|
||||||
sys.stderr = open(os.devnull, 'w')
|
sys.stderr = open(os.devnull, 'w')
|
||||||
|
|
||||||
|
from app.factory import app
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.init import init_db, update_db
|
from app.db.init import init_db, update_db
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -64,6 +64,7 @@ moviepilot config path
|
|||||||
- 前端本地 Node 运行时:`.runtime/node/`
|
- 前端本地 Node 运行时:`.runtime/node/`
|
||||||
- 后端日志:`<Config Dir>/logs/moviepilot.log`
|
- 后端日志:`<Config Dir>/logs/moviepilot.log`
|
||||||
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
|
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
|
||||||
|
该文件同样受 `LOG_MAX_FILE_SIZE` 与 `LOG_BACKUP_COUNT` 控制
|
||||||
- 前端启动日志:`<Config Dir>/logs/moviepilot.frontend.stdout.log`
|
- 前端启动日志:`<Config Dir>/logs/moviepilot.frontend.stdout.log`
|
||||||
|
|
||||||
## 帮助与发现
|
## 帮助与发现
|
||||||
@@ -118,6 +119,9 @@ moviepilot uninstall
|
|||||||
moviepilot update backend
|
moviepilot update backend
|
||||||
moviepilot update frontend
|
moviepilot update frontend
|
||||||
moviepilot update all
|
moviepilot update all
|
||||||
|
moviepilot startup enable
|
||||||
|
moviepilot startup disable
|
||||||
|
moviepilot startup status
|
||||||
moviepilot agent
|
moviepilot agent
|
||||||
moviepilot start
|
moviepilot start
|
||||||
moviepilot stop
|
moviepilot stop
|
||||||
@@ -232,6 +236,8 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
|||||||
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
||||||
- 用户站点认证
|
- 用户站点认证
|
||||||
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
||||||
|
- 开机自启
|
||||||
|
可按需启用,MoviePilot 会根据当前操作系统注册登录自启动
|
||||||
- 下载器
|
- 下载器
|
||||||
- 媒体服务器
|
- 媒体服务器
|
||||||
- 消息通知渠道
|
- 消息通知渠道
|
||||||
@@ -248,6 +254,25 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
|||||||
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
||||||
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
|
- 交互式 `--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 管理的前后端服务
|
- 卸载时会先停止当前 CLI 管理的前后端服务
|
||||||
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
|
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
|
||||||
|
- 如果之前注册过开机自启,卸载时也会一并取消
|
||||||
- 会询问是否同时删除配置目录,默认不删除
|
- 会询问是否同时删除配置目录,默认不删除
|
||||||
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
|
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
|
||||||
- 整个卸载流程包含两次确认
|
- 整个卸载流程包含两次确认
|
||||||
@@ -338,10 +364,12 @@ moviepilot version
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `start` 会先启动后端,再启动前端
|
- `start` 会先启动后端,再启动前端
|
||||||
|
- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动
|
||||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||||
- 后端默认监听 `PORT`,默认值 `3001`
|
- 后端默认监听 `PORT`,默认值 `3001`
|
||||||
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
||||||
|
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
|
||||||
|
|
||||||
日志:
|
日志:
|
||||||
|
|
||||||
|
|||||||
+34
@@ -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 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 uninstall [--venv PATH] [--config-dir PATH]
|
||||||
moviepilot update {backend|frontend|all} [OPTIONS]
|
moviepilot update {backend|frontend|all} [OPTIONS]
|
||||||
|
moviepilot startup {enable|disable|status} [--venv PATH] [--config-dir PATH]
|
||||||
moviepilot agent [OPTIONS] MESSAGE...
|
moviepilot agent [OPTIONS] MESSAGE...
|
||||||
|
|
||||||
Runtime Commands:
|
Runtime Commands:
|
||||||
@@ -30,6 +31,7 @@ Discovery Commands:
|
|||||||
moviepilot help install
|
moviepilot help install
|
||||||
moviepilot help uninstall
|
moviepilot help uninstall
|
||||||
moviepilot help update
|
moviepilot help update
|
||||||
|
moviepilot help startup
|
||||||
moviepilot commands
|
moviepilot commands
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -39,6 +41,7 @@ Examples:
|
|||||||
moviepilot setup --wizard
|
moviepilot setup --wizard
|
||||||
moviepilot uninstall
|
moviepilot uninstall
|
||||||
moviepilot update all
|
moviepilot update all
|
||||||
|
moviepilot startup enable
|
||||||
moviepilot agent 帮我分析最近一次搜索失败
|
moviepilot agent 帮我分析最近一次搜索失败
|
||||||
moviepilot help config
|
moviepilot help config
|
||||||
moviepilot config keys
|
moviepilot config keys
|
||||||
@@ -59,6 +62,9 @@ Bootstrap Commands
|
|||||||
update backend
|
update backend
|
||||||
update frontend
|
update frontend
|
||||||
update all
|
update all
|
||||||
|
startup enable
|
||||||
|
startup disable
|
||||||
|
startup status
|
||||||
agent
|
agent
|
||||||
|
|
||||||
Runtime Commands
|
Runtime Commands
|
||||||
@@ -185,6 +191,25 @@ Options:
|
|||||||
EOF
|
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() {
|
show_agent_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
@@ -328,6 +353,10 @@ show_command_help() {
|
|||||||
show_update_help
|
show_update_help
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
startup)
|
||||||
|
show_startup_help
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
commands)
|
commands)
|
||||||
show_commands
|
show_commands
|
||||||
exit 0
|
exit 0
|
||||||
@@ -432,6 +461,11 @@ case "${1:-}" in
|
|||||||
require_bootstrap_python
|
require_bootstrap_python
|
||||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@"
|
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@"
|
||||||
;;
|
;;
|
||||||
|
startup)
|
||||||
|
shift
|
||||||
|
require_bootstrap_python
|
||||||
|
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" startup "$@"
|
||||||
|
;;
|
||||||
agent)
|
agent)
|
||||||
shift
|
shift
|
||||||
require_bootstrap_python
|
require_bootstrap_python
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import textwrap
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -75,6 +76,183 @@ RUNTIME_PACKAGE = {
|
|||||||
"express-http-proxy": "^2.0.0",
|
"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 = [
|
NOTIFICATION_SWITCH_TYPES = [
|
||||||
"资源下载",
|
"资源下载",
|
||||||
"整理入库",
|
"整理入库",
|
||||||
@@ -88,6 +266,15 @@ NOTIFICATION_SWITCH_TYPES = [
|
|||||||
]
|
]
|
||||||
UNINSTALL_CONFIRM_TEXT = "UNINSTALL"
|
UNINSTALL_CONFIRM_TEXT = "UNINSTALL"
|
||||||
RESOURCE_FILE_PATTERNS = ("sites*", "user.sites*.bin")
|
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:
|
def _default_config_dir() -> Path:
|
||||||
@@ -490,6 +677,16 @@ def _frontend_runtime_ready(frontend_version: str) -> bool:
|
|||||||
return False
|
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]:
|
def _node_platform() -> tuple[str, str]:
|
||||||
system_name = platform.system().lower()
|
system_name = platform.system().lower()
|
||||||
machine = platform.machine().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)
|
node_bin = install_node_runtime(node_version)
|
||||||
|
|
||||||
if _frontend_runtime_ready(version_tag):
|
if _frontend_runtime_ready(version_tag):
|
||||||
|
_write_local_frontend_service_script(PUBLIC_DIR)
|
||||||
print_step(f"前端发布包已是最新版本:{version_tag}")
|
print_step(f"前端发布包已是最新版本:{version_tag}")
|
||||||
return {"version": version_tag, "node": str(node_bin)}
|
return {"version": version_tag, "node": str(node_bin)}
|
||||||
|
|
||||||
@@ -578,6 +776,8 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]
|
|||||||
_remove_path(PUBLIC_DIR)
|
_remove_path(PUBLIC_DIR)
|
||||||
shutil.move(str(dist_dir), str(PUBLIC_DIR))
|
shutil.move(str(dist_dir), str(PUBLIC_DIR))
|
||||||
|
|
||||||
|
_write_local_frontend_service_script(PUBLIC_DIR)
|
||||||
|
|
||||||
runtime_package = dict(RUNTIME_PACKAGE)
|
runtime_package = dict(RUNTIME_PACKAGE)
|
||||||
runtime_package["version"] = version_tag
|
runtime_package["version"] = version_tag
|
||||||
(PUBLIC_DIR / "package.json").write_text(
|
(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(
|
def run_setup_wizard(
|
||||||
force_token: bool,
|
force_token: bool,
|
||||||
runtime_python: Optional[Path] = None,
|
runtime_python: Optional[Path] = None,
|
||||||
@@ -1492,6 +1709,7 @@ def run_setup_wizard(
|
|||||||
"mediaserver": _collect_media_server_config(),
|
"mediaserver": _collect_media_server_config(),
|
||||||
"notification": _collect_notification_config(),
|
"notification": _collect_notification_config(),
|
||||||
"site_auth": _collect_site_auth_config(runtime_python=runtime_python),
|
"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(
|
def init_local(
|
||||||
*,
|
*,
|
||||||
resources_repo: Optional[Path],
|
resources_repo: Optional[Path],
|
||||||
@@ -1793,6 +2042,7 @@ def init_local(
|
|||||||
superuser: Optional[str],
|
superuser: Optional[str],
|
||||||
superuser_password: Optional[str],
|
superuser_password: Optional[str],
|
||||||
runtime_python: Optional[Path] = None,
|
runtime_python: Optional[Path] = None,
|
||||||
|
venv_dir: Optional[Path] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ensure_local_dirs()
|
ensure_local_dirs()
|
||||||
|
|
||||||
@@ -1842,6 +2092,17 @@ def init_local(
|
|||||||
elif direct_env_settings:
|
elif direct_env_settings:
|
||||||
sync_superuser_account(runtime_python=runtime_python)
|
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:
|
def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
|
||||||
ensure_supported_python(python_bin)
|
ensure_supported_python(python_bin)
|
||||||
@@ -1869,6 +2130,496 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
|
|||||||
return venv_python
|
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]]:
|
def _read_runtime_file(path: Path) -> Optional[dict[str, Any]]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -2074,6 +2825,7 @@ def uninstall_local(
|
|||||||
for path in cli_links
|
for path in cli_links
|
||||||
if path.is_symlink() and path.exists() and path.resolve() == script_path
|
if path.is_symlink() and path.exists() and path.resolve() == script_path
|
||||||
]
|
]
|
||||||
|
autostart_status = _autostart_status()
|
||||||
|
|
||||||
delete_config = _prompt_yes_no(
|
delete_config = _prompt_yes_no(
|
||||||
f"是否同时删除配置目录 {config_dir}", default=False
|
f"是否同时删除配置目录 {config_dir}", default=False
|
||||||
@@ -2091,6 +2843,12 @@ def uninstall_local(
|
|||||||
print(f" {path}")
|
print(f" {path}")
|
||||||
else:
|
else:
|
||||||
print(" - 未检测到指向当前仓库的全局 CLI 软链接")
|
print(" - 未检测到指向当前仓库的全局 CLI 软链接")
|
||||||
|
if autostart_status.get("enabled"):
|
||||||
|
print(
|
||||||
|
f" - 取消开机自启:{autostart_status.get('label') or _startup_platform_name()}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - 当前未配置开机自启")
|
||||||
|
|
||||||
if delete_config:
|
if delete_config:
|
||||||
print(f" - 删除配置目录:{config_dir}")
|
print(f" - 删除配置目录:{config_dir}")
|
||||||
@@ -2112,6 +2870,8 @@ def uninstall_local(
|
|||||||
return {"cancelled": True}
|
return {"cancelled": True}
|
||||||
|
|
||||||
_stop_managed_services(venv_dir=venv_dir)
|
_stop_managed_services(venv_dir=venv_dir)
|
||||||
|
if autostart_status.get("enabled"):
|
||||||
|
disable_autostart()
|
||||||
|
|
||||||
removed_paths: list[Path] = []
|
removed_paths: list[Path] = []
|
||||||
removed_paths.extend(
|
removed_paths.extend(
|
||||||
@@ -2210,6 +2970,44 @@ def update_backend(
|
|||||||
return venv_python
|
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(
|
def run_agent_request(
|
||||||
*, message: str, session_id: Optional[str], new_session: bool, user_id: str
|
*, message: str, session_id: Optional[str], new_session: bool, user_id: str
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -2406,6 +3204,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
"--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 = subparsers.add_parser("apply-config", help=argparse.SUPPRESS)
|
||||||
apply_config_parser.add_argument(
|
apply_config_parser.add_argument(
|
||||||
"--config-json-file", required=True, help=argparse.SUPPRESS
|
"--config-json-file", required=True, help=argparse.SUPPRESS
|
||||||
@@ -2490,6 +3301,7 @@ def main() -> int:
|
|||||||
superuser=args.superuser,
|
superuser=args.superuser,
|
||||||
superuser_password=args.superuser_password,
|
superuser_password=args.superuser_password,
|
||||||
runtime_python=None,
|
runtime_python=None,
|
||||||
|
venv_dir=ROOT / "venv",
|
||||||
)
|
)
|
||||||
print_step("初始化完成")
|
print_step("初始化完成")
|
||||||
print_step(f"当前配置目录:{config_dir}")
|
print_step(f"当前配置目录:{config_dir}")
|
||||||
@@ -2525,6 +3337,7 @@ def main() -> int:
|
|||||||
superuser=args.superuser,
|
superuser=args.superuser,
|
||||||
superuser_password=args.superuser_password,
|
superuser_password=args.superuser_password,
|
||||||
runtime_python=venv_python,
|
runtime_python=venv_python,
|
||||||
|
venv_dir=Path(args.venv),
|
||||||
)
|
)
|
||||||
print_step(f"本地环境已完成安装与初始化:{venv_python}")
|
print_step(f"本地环境已完成安装与初始化:{venv_python}")
|
||||||
print_step(f"当前配置目录:{config_dir}")
|
print_step(f"当前配置目录:{config_dir}")
|
||||||
@@ -2572,6 +3385,20 @@ def main() -> int:
|
|||||||
print_step(f"更新完成,当前配置目录:{config_dir}")
|
print_step(f"更新完成,当前配置目录:{config_dir}")
|
||||||
return 0
|
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":
|
if args.command == "apply-config":
|
||||||
payload = json.loads(
|
payload = json.loads(
|
||||||
Path(args.config_json_file).read_text(encoding="utf-8")
|
Path(args.config_json_file).read_text(encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user