mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-07 05:43:55 +08:00
add full-stack local cli install flow
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,11 +15,14 @@ app/helper/*.bin
|
||||
app/plugins/**
|
||||
!app/plugins/__init__.py
|
||||
config/cookies/**
|
||||
config/app.env
|
||||
config/user.db*
|
||||
config/sites/**
|
||||
config/logs/
|
||||
config/temp/
|
||||
config/cache/
|
||||
.runtime/
|
||||
public/
|
||||
*.pyc
|
||||
*.log
|
||||
.vscode
|
||||
|
||||
43
README.md
43
README.md
@@ -16,17 +16,31 @@
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
|
||||
## 主要特性
|
||||
|
||||
- 前后端分离,基于FastApi + Vue3。
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
|
||||
## 安装使用
|
||||
|
||||
官方Wiki:https://wiki.movie-pilot.org
|
||||
|
||||
### 为 AI Agent 添加 Skills
|
||||
|
||||
## 本地 CLI
|
||||
|
||||
一键安装运行脚本:
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
|
||||
```
|
||||
|
||||
使用 `moviepilot` 命令管理MoviePilot,完整 CLI 文档:[`docs/cli.md`](docs/cli.md)
|
||||
|
||||
|
||||
## 为 AI Agent 添加 Skills
|
||||
```shell
|
||||
npx skills add https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
@@ -37,32 +51,9 @@ API文档:https://api.movie-pilot.org
|
||||
|
||||
MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
|
||||
|
||||
本地运行需要 `Python 3.12`、`Node JS v20.12.1`
|
||||
开发环境准备与本地源码运行说明:[`docs/development-setup.md`](docs/development-setup.md)
|
||||
|
||||
- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot)
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot
|
||||
```
|
||||
- 克隆资源项目 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) ,将 `resources` 目录下对应平台及版本的库 `.so`/`.pyd`/`.bin` 文件复制到 `app/helper` 目录
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot-Resources
|
||||
```
|
||||
- 安装后端依赖,运行 `main.py` 启动后端服务,默认监听端口:`3001`,API文档地址:`http://localhost:3001/docs`
|
||||
```shell
|
||||
cd MoviePilot
|
||||
pip install -r requirements.txt
|
||||
python3 -m app.main
|
||||
```
|
||||
- 克隆前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
```shell
|
||||
git clone https://github.com/jxxghp/MoviePilot-Frontend
|
||||
```
|
||||
- 安装前端依赖,运行前端项目,访问:`http://localhost:5173`
|
||||
```shell
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
- 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码
|
||||
插件开发说明:<https://wiki.movie-pilot.org/zh/plugindev>
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
985
app/cli.py
Normal file
985
app/cli.py
Normal file
@@ -0,0 +1,985 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
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
|
||||
|
||||
import click
|
||||
import psutil
|
||||
|
||||
from app.core.config import Settings, settings
|
||||
from version import APP_VERSION
|
||||
|
||||
BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
BACKEND_APP_LOG_FILE = settings.LOG_PATH / "moviepilot.log"
|
||||
FRONTEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.frontend.runtime.json"
|
||||
FRONTEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.frontend.stdout.log"
|
||||
FRONTEND_DIR = settings.ROOT_PATH / "public"
|
||||
FRONTEND_SERVICE_FILE = FRONTEND_DIR / "service.js"
|
||||
FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
|
||||
HEALTH_PATH = "/api/v1/system/global"
|
||||
HEALTH_TOKEN = "moviepilot"
|
||||
FRONTEND_HEALTH_PATH = "/version.txt"
|
||||
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
||||
MASKED_FIELDS = {
|
||||
"API_TOKEN",
|
||||
"DB_POSTGRESQL_PASSWORD",
|
||||
"RESOURCE_SECRET_KEY",
|
||||
"SECRET_KEY",
|
||||
"SUPERUSER_PASSWORD",
|
||||
}
|
||||
MASKED_SUFFIXES = ("_TOKEN", "_PASSWORD", "_SECRET", "_API_KEY")
|
||||
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return settings.ROOT_PATH
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_json_file(path: Path, payload: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _clear_json_file(path: Path) -> None:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _get_process(runtime: Optional[Dict[str, Any]] = None) -> Optional[psutil.Process]:
|
||||
runtime = runtime or {}
|
||||
pid = runtime.get("pid")
|
||||
create_time = runtime.get("create_time")
|
||||
if not pid or create_time is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
process = psutil.Process(int(pid))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
if abs(process.create_time() - float(create_time)) > 2:
|
||||
return None
|
||||
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
|
||||
return None
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
return None
|
||||
|
||||
return process
|
||||
|
||||
|
||||
def _client_host(host: Optional[str]) -> str:
|
||||
host = (host or "").strip()
|
||||
if host in LOCAL_HOSTS:
|
||||
return "127.0.0.1"
|
||||
return host
|
||||
|
||||
|
||||
def _backend_runtime() -> Optional[Dict[str, Any]]:
|
||||
return _read_json_file(BACKEND_RUNTIME_FILE)
|
||||
|
||||
|
||||
def _frontend_runtime() -> Optional[Dict[str, Any]]:
|
||||
return _read_json_file(FRONTEND_RUNTIME_FILE)
|
||||
|
||||
|
||||
def _backend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str:
|
||||
runtime = runtime or _backend_runtime() or {}
|
||||
host = runtime.get("host") or settings.HOST
|
||||
port = runtime.get("port") or settings.PORT
|
||||
return f"http://{_client_host(host)}:{port}"
|
||||
|
||||
|
||||
def _frontend_base_url(runtime: Optional[Dict[str, Any]] = None) -> str:
|
||||
runtime = runtime or _frontend_runtime() or {}
|
||||
host = runtime.get("host") or settings.HOST
|
||||
port = runtime.get("port") or settings.NGINX_PORT
|
||||
return f"http://{_client_host(host)}:{port}"
|
||||
|
||||
|
||||
def _runtime_api_token(runtime: Optional[Dict[str, Any]] = None) -> str:
|
||||
runtime = runtime or _backend_runtime() or {}
|
||||
return runtime.get("api_token") or settings.API_TOKEN
|
||||
|
||||
|
||||
def _http_request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: float = 5.0,
|
||||
runtime: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{_backend_base_url(runtime)}{path}"
|
||||
if params:
|
||||
query = urlencode(params, doseq=True)
|
||||
url = f"{url}?{query}"
|
||||
|
||||
body = None
|
||||
request_headers = {"Accept": "application/json"}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
if json_body is not None:
|
||||
body = json.dumps(json_body).encode("utf-8")
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
|
||||
request = Request(url=url, data=body, headers=request_headers, method=method.upper())
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return {
|
||||
"status": response.status,
|
||||
"json": json.loads(raw) if raw else None,
|
||||
"text": raw,
|
||||
}
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", errors="ignore")
|
||||
try:
|
||||
data = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
data = None
|
||||
return {
|
||||
"status": exc.code,
|
||||
"json": data,
|
||||
"text": raw,
|
||||
}
|
||||
except URLError as exc:
|
||||
raise click.ClickException(f"无法连接到本地服务:{exc.reason}") from exc
|
||||
|
||||
|
||||
def _backend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0) -> tuple[bool, Optional[Dict[str, Any]]]:
|
||||
try:
|
||||
response = _http_request(
|
||||
"GET",
|
||||
HEALTH_PATH,
|
||||
params={"token": HEALTH_TOKEN},
|
||||
timeout=timeout,
|
||||
runtime=runtime,
|
||||
)
|
||||
except click.ClickException:
|
||||
return False, None
|
||||
|
||||
payload = response.get("json")
|
||||
if response["status"] != 200 or not isinstance(payload, dict):
|
||||
return False, None
|
||||
if payload.get("success") is False:
|
||||
return False, payload
|
||||
return True, payload
|
||||
|
||||
|
||||
def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = 2.0) -> tuple[bool, Optional[Dict[str, Any]]]:
|
||||
runtime = runtime or _frontend_runtime() or {}
|
||||
url = f"{_frontend_base_url(runtime)}{FRONTEND_HEALTH_PATH}"
|
||||
request = Request(url=url, headers={"Accept": "text/plain"}, method="GET")
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8", errors="ignore").strip()
|
||||
return response.status == 200, {"version": raw}
|
||||
except (HTTPError, URLError):
|
||||
return False, None
|
||||
|
||||
|
||||
def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
|
||||
runtime = _backend_runtime()
|
||||
process = _get_process(runtime)
|
||||
if process:
|
||||
healthy, health_payload = _backend_health(runtime=runtime)
|
||||
if healthy:
|
||||
return "running", runtime, process, health_payload
|
||||
return "starting", runtime, process, None
|
||||
|
||||
if runtime:
|
||||
_clear_json_file(BACKEND_RUNTIME_FILE)
|
||||
|
||||
healthy, health_payload = _backend_health()
|
||||
if healthy:
|
||||
return "running-unmanaged", None, None, health_payload
|
||||
return "stopped", None, None, None
|
||||
|
||||
|
||||
def _managed_frontend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
|
||||
runtime = _frontend_runtime()
|
||||
process = _get_process(runtime)
|
||||
if process:
|
||||
healthy, health_payload = _frontend_health(runtime=runtime)
|
||||
if healthy:
|
||||
return "running", runtime, process, health_payload
|
||||
return "starting", runtime, process, None
|
||||
|
||||
if runtime:
|
||||
_clear_json_file(FRONTEND_RUNTIME_FILE)
|
||||
|
||||
healthy, health_payload = _frontend_health()
|
||||
if healthy:
|
||||
return "running-unmanaged", None, None, health_payload
|
||||
return "stopped", None, None, None
|
||||
|
||||
|
||||
def _mask_value(key: str, value: Any, show_secrets: bool = False) -> Any:
|
||||
is_secret = key in MASKED_FIELDS or key.endswith(MASKED_SUFFIXES)
|
||||
if show_secrets or not is_secret:
|
||||
return value
|
||||
if value in (None, "", []):
|
||||
return value
|
||||
return "******"
|
||||
|
||||
|
||||
def _format_value(value: Any) -> str:
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _field_default(field: Any) -> Any:
|
||||
default_factory = getattr(field, "default_factory", None)
|
||||
if default_factory is not None:
|
||||
try:
|
||||
return default_factory()
|
||||
except TypeError:
|
||||
return "(dynamic)"
|
||||
return getattr(field, "default", None)
|
||||
|
||||
|
||||
def _annotation_name(annotation: Any) -> str:
|
||||
origin = get_origin(annotation)
|
||||
if origin is None:
|
||||
if hasattr(annotation, "__name__"):
|
||||
return annotation.__name__
|
||||
return str(annotation).replace("typing.", "")
|
||||
|
||||
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
if origin in {list, set, tuple}:
|
||||
inner = _annotation_name(args[0]) if args else "Any"
|
||||
return f"{origin.__name__}[{inner}]"
|
||||
if origin is dict:
|
||||
if len(args) >= 2:
|
||||
return f"dict[{_annotation_name(args[0])}, {_annotation_name(args[1])}]"
|
||||
return "dict"
|
||||
if str(origin).endswith("Union"):
|
||||
if len(args) == 1:
|
||||
return f"Optional[{_annotation_name(args[0])}]"
|
||||
return " | ".join(_annotation_name(arg) for arg in args)
|
||||
return str(annotation).replace("typing.", "")
|
||||
|
||||
|
||||
def _tail_lines(path: Path, count: int) -> list[str]:
|
||||
if not path.exists():
|
||||
raise click.ClickException(f"日志文件不存在:{path}")
|
||||
with path.open("r", encoding="utf-8", errors="ignore") as handle:
|
||||
return [line.rstrip("\n") for line in deque(handle, maxlen=count)]
|
||||
|
||||
|
||||
def _follow_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
raise click.ClickException(f"日志文件不存在:{path}")
|
||||
|
||||
with path.open("r", encoding="utf-8", errors="ignore") as handle:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
while True:
|
||||
line = handle.readline()
|
||||
if line:
|
||||
click.echo(line.rstrip("\n"))
|
||||
continue
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _print_json(value: Any) -> None:
|
||||
click.echo(json.dumps(value, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _parse_tool_result(result: Any) -> Any:
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
try:
|
||||
return json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
return result
|
||||
|
||||
|
||||
def _tool_request_headers(runtime: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||
api_token = _runtime_api_token(runtime)
|
||||
if not api_token:
|
||||
raise click.ClickException("本地配置中未找到 API_TOKEN,请先配置后再使用 tool/scheduler 命令")
|
||||
return {"X-API-KEY": api_token}
|
||||
|
||||
|
||||
def _call_tool(tool_name: str, arguments: Dict[str, Any], runtime: Optional[Dict[str, Any]] = None) -> Any:
|
||||
response = _http_request(
|
||||
"POST",
|
||||
"/api/v1/mcp/tools/call",
|
||||
json_body={"tool_name": tool_name, "arguments": arguments},
|
||||
headers=_tool_request_headers(runtime),
|
||||
timeout=30.0,
|
||||
runtime=runtime,
|
||||
)
|
||||
payload = response.get("json") or {}
|
||||
if response["status"] not in {200, 201}:
|
||||
message = payload.get("error") or payload.get("detail") or response["text"] or "调用工具失败"
|
||||
raise click.ClickException(message)
|
||||
if not payload.get("success"):
|
||||
raise click.ClickException(payload.get("error") or "调用工具失败")
|
||||
return _parse_tool_result(payload.get("result"))
|
||||
|
||||
|
||||
def _load_tool(tool_name: str, runtime: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
response = _http_request(
|
||||
"GET",
|
||||
f"/api/v1/mcp/tools/{tool_name}",
|
||||
headers=_tool_request_headers(runtime),
|
||||
timeout=10.0,
|
||||
runtime=runtime,
|
||||
)
|
||||
if response["status"] == 404:
|
||||
raise click.ClickException(f"工具不存在:{tool_name}")
|
||||
if response["status"] != 200 or not isinstance(response.get("json"), dict):
|
||||
raise click.ClickException(response["text"] or f"获取工具失败(HTTP {response['status']})")
|
||||
return response["json"]
|
||||
|
||||
|
||||
def _load_tools(runtime: Optional[Dict[str, Any]] = None) -> list[Dict[str, Any]]:
|
||||
response = _http_request(
|
||||
"GET",
|
||||
"/api/v1/mcp/tools",
|
||||
headers=_tool_request_headers(runtime),
|
||||
timeout=10.0,
|
||||
runtime=runtime,
|
||||
)
|
||||
if response["status"] != 200 or not isinstance(response.get("json"), list):
|
||||
raise click.ClickException(response["text"] or f"获取工具列表失败(HTTP {response['status']})")
|
||||
return response["json"]
|
||||
|
||||
|
||||
def _normalize_type(schema: Optional[Dict[str, Any]]) -> str:
|
||||
schema = schema or {}
|
||||
if schema.get("type"):
|
||||
return str(schema["type"])
|
||||
for item in schema.get("anyOf", []):
|
||||
if item and item.get("type") and item.get("type") != "null":
|
||||
return str(item["type"])
|
||||
return "string"
|
||||
|
||||
|
||||
def _format_tool_detail(tool: Dict[str, Any]) -> None:
|
||||
click.echo(f"Command: {tool.get('name')}")
|
||||
click.echo(f"Description: {tool.get('description') or '(none)'}")
|
||||
click.echo("")
|
||||
|
||||
properties = (tool.get("inputSchema") or {}).get("properties") or {}
|
||||
required = set((tool.get("inputSchema") or {}).get("required") or [])
|
||||
fields = []
|
||||
for name, schema in properties.items():
|
||||
if name == "explanation":
|
||||
continue
|
||||
fields.append(
|
||||
(
|
||||
f"{name}*" if name in required else name,
|
||||
_normalize_type(schema),
|
||||
schema.get("description") or "",
|
||||
)
|
||||
)
|
||||
|
||||
if not fields:
|
||||
click.echo("Parameters: (none)")
|
||||
else:
|
||||
name_width = max(len(name) for name, _, _ in fields)
|
||||
type_width = max(len(field_type) for _, field_type, _ in fields)
|
||||
click.echo("Parameters:")
|
||||
for field_name, field_type, field_desc in fields:
|
||||
click.echo(f" {field_name.ljust(name_width)} {field_type.ljust(type_width)} {field_desc}")
|
||||
|
||||
|
||||
def _parse_key_value_pairs(items: Iterable[str]) -> Dict[str, str]:
|
||||
payload: Dict[str, str] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise click.ClickException(f"参数必须是 key=value 形式:{item}")
|
||||
key, value = item.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise click.ClickException(f"参数名不能为空:{item}")
|
||||
payload[key] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _ensure_local_api_token() -> bool:
|
||||
if settings.API_TOKEN and len(str(settings.API_TOKEN).strip()) >= 16:
|
||||
return False
|
||||
|
||||
result, message = settings.update_setting("API_TOKEN", settings.API_TOKEN or "")
|
||||
if result is False:
|
||||
raise click.ClickException(message or "初始化 API_TOKEN 失败")
|
||||
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")
|
||||
|
||||
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 os.name == "nt":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
return subprocess.Popen(command, **kwargs)
|
||||
|
||||
|
||||
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"},
|
||||
)
|
||||
|
||||
|
||||
def _frontend_node_binary() -> Path:
|
||||
candidates = [
|
||||
_repo_root() / ".runtime" / "node" / "bin" / "node",
|
||||
_repo_root() / ".runtime" / "node" / "node.exe",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
system_node = shutil.which("node")
|
||||
if system_node:
|
||||
return Path(system_node)
|
||||
|
||||
raise click.ClickException("未找到可用的 Node 运行时,请先执行 `moviepilot install frontend` 或 `moviepilot setup`")
|
||||
|
||||
|
||||
def _ensure_frontend_runtime() -> None:
|
||||
if not FRONTEND_SERVICE_FILE.exists():
|
||||
raise click.ClickException("未找到前端发布包,请先执行 `moviepilot install frontend` 或 `moviepilot setup`")
|
||||
if not (FRONTEND_DIR / "node_modules" / "express").exists():
|
||||
raise click.ClickException("前端运行依赖未安装,请重新执行 `moviepilot install frontend` 或 `moviepilot setup`")
|
||||
|
||||
|
||||
def _spawn_frontend_process(backend_port: int) -> subprocess.Popen:
|
||||
_ensure_frontend_runtime()
|
||||
node_bin = _frontend_node_binary()
|
||||
return _spawn_process(
|
||||
[str(node_bin), str(FRONTEND_SERVICE_FILE)],
|
||||
cwd=FRONTEND_DIR,
|
||||
log_file=FRONTEND_STDIO_LOG_FILE,
|
||||
env={
|
||||
**os.environ,
|
||||
"PORT": str(backend_port),
|
||||
"NGINX_PORT": str(settings.NGINX_PORT),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _wait_until_backend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[str, Any]:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
process = _get_process(runtime)
|
||||
if not process:
|
||||
lines = _tail_lines(BACKEND_STDIO_LOG_FILE, 20) if BACKEND_STDIO_LOG_FILE.exists() else []
|
||||
_clear_json_file(BACKEND_RUNTIME_FILE)
|
||||
detail = "\n".join(lines) if lines else "请查看后端日志文件排查问题。"
|
||||
raise click.ClickException(f"后端启动失败。\n{detail}")
|
||||
|
||||
healthy, payload = _backend_health(runtime=runtime)
|
||||
if healthy:
|
||||
return payload or {}
|
||||
time.sleep(1)
|
||||
|
||||
raise click.ClickException(f"后端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --stdio` 查看启动日志")
|
||||
|
||||
|
||||
def _wait_until_frontend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[str, Any]:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
process = _get_process(runtime)
|
||||
if not process:
|
||||
lines = _tail_lines(FRONTEND_STDIO_LOG_FILE, 20) if FRONTEND_STDIO_LOG_FILE.exists() else []
|
||||
_clear_json_file(FRONTEND_RUNTIME_FILE)
|
||||
detail = "\n".join(lines) if lines else "请查看前端日志文件排查问题。"
|
||||
raise click.ClickException(f"前端启动失败。\n{detail}")
|
||||
|
||||
healthy, payload = _frontend_health(runtime=runtime)
|
||||
if healthy:
|
||||
return payload or {}
|
||||
time.sleep(1)
|
||||
|
||||
raise click.ClickException(f"前端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --frontend` 查看前端日志")
|
||||
|
||||
|
||||
def _start_backend_service(timeout: int) -> Dict[str, Any]:
|
||||
state, runtime, process, health_payload = _managed_backend_status()
|
||||
if state in {"running", "starting"} and runtime and process:
|
||||
return {"status": state, "runtime": runtime, "process": process, "health": health_payload, "started": False}
|
||||
if state == "running-unmanaged":
|
||||
raise click.ClickException("检测到本地端口上已有 MoviePilot 后端正在运行,但不是由当前 CLI 管理,请先手动停止它")
|
||||
|
||||
_ensure_local_api_token()
|
||||
_clear_json_file(BACKEND_RUNTIME_FILE)
|
||||
process = _spawn_backend_process()
|
||||
ps_process = psutil.Process(process.pid)
|
||||
runtime = {
|
||||
"pid": process.pid,
|
||||
"create_time": ps_process.create_time(),
|
||||
"host": settings.HOST,
|
||||
"port": settings.PORT,
|
||||
"api_token": settings.API_TOKEN,
|
||||
"started_at": int(time.time()),
|
||||
"python": sys.executable,
|
||||
"stdio_log": str(BACKEND_STDIO_LOG_FILE),
|
||||
}
|
||||
_write_json_file(BACKEND_RUNTIME_FILE, runtime)
|
||||
health_payload = _wait_until_backend_ready(runtime, timeout)
|
||||
return {"status": "running", "runtime": runtime, "process": ps_process, "health": health_payload, "started": True}
|
||||
|
||||
|
||||
def _start_frontend_service(timeout: int, backend_port: int) -> Dict[str, Any]:
|
||||
state, runtime, process, health_payload = _managed_frontend_status()
|
||||
if state in {"running", "starting"} and runtime and process:
|
||||
return {"status": state, "runtime": runtime, "process": process, "health": health_payload, "started": False}
|
||||
if state == "running-unmanaged":
|
||||
raise click.ClickException("检测到本地端口上已有 MoviePilot 前端正在运行,但不是由当前 CLI 管理,请先手动停止它")
|
||||
|
||||
_clear_json_file(FRONTEND_RUNTIME_FILE)
|
||||
process = _spawn_frontend_process(backend_port=backend_port)
|
||||
ps_process = psutil.Process(process.pid)
|
||||
runtime = {
|
||||
"pid": process.pid,
|
||||
"create_time": ps_process.create_time(),
|
||||
"host": settings.HOST,
|
||||
"port": settings.NGINX_PORT,
|
||||
"backend_port": backend_port,
|
||||
"started_at": int(time.time()),
|
||||
"node": str(_frontend_node_binary()),
|
||||
"stdio_log": str(FRONTEND_STDIO_LOG_FILE),
|
||||
}
|
||||
_write_json_file(FRONTEND_RUNTIME_FILE, runtime)
|
||||
health_payload = _wait_until_frontend_ready(runtime, timeout)
|
||||
return {"status": "running", "runtime": runtime, "process": ps_process, "health": health_payload, "started": True}
|
||||
|
||||
|
||||
def _terminate_process(runtime_file: Path, timeout: int, force: bool, component_name: str) -> Dict[str, Any]:
|
||||
runtime = _read_json_file(runtime_file)
|
||||
process = _get_process(runtime)
|
||||
if not process:
|
||||
if runtime:
|
||||
_clear_json_file(runtime_file)
|
||||
return {"stopped": False}
|
||||
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except psutil.TimeoutExpired:
|
||||
if not force:
|
||||
raise click.ClickException(f"{component_name} 在 {timeout} 秒内没有退出,可重新执行 `moviepilot stop --force` 强制终止")
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
|
||||
_clear_json_file(runtime_file)
|
||||
return {"stopped": True, "pid": process.pid}
|
||||
|
||||
|
||||
def _stop_backend_service(timeout: int, force: bool) -> Dict[str, Any]:
|
||||
runtime = _backend_runtime()
|
||||
process = _get_process(runtime)
|
||||
if not process:
|
||||
if runtime:
|
||||
_clear_json_file(BACKEND_RUNTIME_FILE)
|
||||
healthy, _ = _backend_health()
|
||||
if healthy:
|
||||
raise click.ClickException("后端正在运行,但不是由当前 CLI 管理,出于安全原因未执行停止")
|
||||
return {"stopped": False}
|
||||
return _terminate_process(BACKEND_RUNTIME_FILE, timeout, force, "后端服务")
|
||||
|
||||
|
||||
def _stop_frontend_service(timeout: int, force: bool) -> Dict[str, Any]:
|
||||
runtime = _frontend_runtime()
|
||||
process = _get_process(runtime)
|
||||
if not process:
|
||||
if runtime:
|
||||
_clear_json_file(FRONTEND_RUNTIME_FILE)
|
||||
healthy, _ = _frontend_health()
|
||||
if healthy:
|
||||
raise click.ClickException("前端正在运行,但不是由当前 CLI 管理,出于安全原因未执行停止")
|
||||
return {"stopped": False}
|
||||
return _terminate_process(FRONTEND_RUNTIME_FILE, timeout, force, "前端服务")
|
||||
|
||||
|
||||
def _installed_frontend_version() -> Optional[str]:
|
||||
if not FRONTEND_VERSION_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
def cli() -> None:
|
||||
"""MoviePilot 本地 CLI"""
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数")
|
||||
def start(timeout: int) -> None:
|
||||
"""后台启动本地 MoviePilot 前后端服务"""
|
||||
backend_result = _start_backend_service(timeout=timeout)
|
||||
backend_runtime = backend_result["runtime"]
|
||||
try:
|
||||
frontend_result = _start_frontend_service(timeout=timeout, backend_port=int(backend_runtime["port"]))
|
||||
except Exception:
|
||||
if backend_result.get("started"):
|
||||
try:
|
||||
_stop_backend_service(timeout=15, force=True)
|
||||
except click.ClickException:
|
||||
pass
|
||||
raise
|
||||
|
||||
backend_health = backend_result.get("health") or {}
|
||||
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get("BACKEND_VERSION", APP_VERSION)
|
||||
frontend_version = ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get("version") or _installed_frontend_version() or "unknown"
|
||||
|
||||
click.echo("MoviePilot 已启动" if backend_result.get("started") or frontend_result.get("started") else "MoviePilot 已在运行")
|
||||
click.echo(f"Backend PID: {backend_result['process'].pid}")
|
||||
click.echo(f"Backend URL: {_backend_base_url(backend_runtime)}")
|
||||
click.echo(f"Frontend PID: {frontend_result['process'].pid}")
|
||||
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
|
||||
click.echo(f"Backend Version: {backend_version}")
|
||||
click.echo(f"Frontend Version: {frontend_version}")
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--timeout", default=30, show_default=True, help="等待服务退出的秒数")
|
||||
@click.option("--force", is_flag=True, help="超时后强制结束进程")
|
||||
def stop(timeout: int, force: bool) -> None:
|
||||
"""停止本地 MoviePilot 前后端服务"""
|
||||
frontend_result = _stop_frontend_service(timeout=timeout, force=force)
|
||||
backend_result = _stop_backend_service(timeout=timeout, force=force)
|
||||
|
||||
if not frontend_result.get("stopped") and not backend_result.get("stopped"):
|
||||
click.echo("MoviePilot 当前未运行")
|
||||
return
|
||||
if frontend_result.get("stopped"):
|
||||
click.echo(f"前端已停止 (PID: {frontend_result['pid']})")
|
||||
if backend_result.get("stopped"):
|
||||
click.echo(f"后端已停止 (PID: {backend_result['pid']})")
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--start-timeout", default=60, show_default=True, help="重启后等待服务就绪的秒数")
|
||||
@click.option("--stop-timeout", default=30, show_default=True, help="停止服务时等待退出的秒数")
|
||||
@click.option("--force", is_flag=True, help="停止超时后强制结束进程")
|
||||
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)
|
||||
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 已重启")
|
||||
click.echo(f"Backend URL: {_backend_base_url(backend_result['runtime'])}")
|
||||
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
def status() -> None:
|
||||
"""查看本地 MoviePilot 前后端服务状态"""
|
||||
backend_state, backend_runtime, backend_process, backend_health = _managed_backend_status()
|
||||
frontend_state, frontend_runtime, frontend_process, frontend_health = _managed_frontend_status()
|
||||
|
||||
if backend_state == "stopped" and frontend_state == "stopped":
|
||||
click.echo("MoviePilot 未运行")
|
||||
installed_frontend = _installed_frontend_version()
|
||||
if installed_frontend:
|
||||
click.echo(f"已安装前端版本: {installed_frontend}")
|
||||
return
|
||||
|
||||
click.echo("Backend:")
|
||||
if backend_state == "stopped":
|
||||
click.echo(" stopped")
|
||||
elif backend_state == "running-unmanaged":
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(" running (unmanaged)")
|
||||
click.echo(f" URL: {_backend_base_url()}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
else:
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(f" {'running' if backend_state == 'running' else 'starting'}")
|
||||
click.echo(f" PID: {backend_process.pid}")
|
||||
click.echo(f" URL: {_backend_base_url(backend_runtime)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(f" App Log: {BACKEND_APP_LOG_FILE}")
|
||||
click.echo(f" Stdout Log: {BACKEND_STDIO_LOG_FILE}")
|
||||
|
||||
click.echo("Frontend:")
|
||||
if frontend_state == "stopped":
|
||||
click.echo(" stopped")
|
||||
installed_frontend = _installed_frontend_version()
|
||||
if installed_frontend:
|
||||
click.echo(f" Installed Version: {installed_frontend}")
|
||||
elif frontend_state == "running-unmanaged":
|
||||
frontend_version = ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None) or _installed_frontend_version() or "unknown"
|
||||
click.echo(" running (unmanaged)")
|
||||
click.echo(f" URL: {_frontend_base_url()}")
|
||||
click.echo(f" Version: {frontend_version}")
|
||||
else:
|
||||
frontend_version = ((frontend_health or {}).get("version") if isinstance(frontend_health, dict) else None) or _installed_frontend_version() or "unknown"
|
||||
click.echo(f" {'running' if frontend_state == 'running' else 'starting'}")
|
||||
click.echo(f" PID: {frontend_process.pid}")
|
||||
click.echo(f" URL: {_frontend_base_url(frontend_runtime)}")
|
||||
click.echo(f" Version: {frontend_version}")
|
||||
click.echo(f" Stdout Log: {FRONTEND_STDIO_LOG_FILE}")
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--lines", default=50, show_default=True, help="显示末尾多少行")
|
||||
@click.option("-f", "--follow", is_flag=True, help="持续跟随日志输出")
|
||||
@click.option("--stdio", is_flag=True, help="查看后端启动标准输出日志而不是应用日志")
|
||||
@click.option("--frontend", "frontend_log", is_flag=True, help="查看前端标准输出日志")
|
||||
def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
|
||||
"""查看本地日志"""
|
||||
if stdio and frontend_log:
|
||||
raise click.ClickException("`--stdio` 与 `--frontend` 不能同时使用")
|
||||
|
||||
if frontend_log:
|
||||
log_file = FRONTEND_STDIO_LOG_FILE
|
||||
elif stdio:
|
||||
log_file = BACKEND_STDIO_LOG_FILE
|
||||
else:
|
||||
log_file = BACKEND_APP_LOG_FILE
|
||||
|
||||
for line in _tail_lines(log_file, lines):
|
||||
click.echo(line)
|
||||
if follow:
|
||||
_follow_file(log_file)
|
||||
|
||||
|
||||
@cli.group(context_settings=CONTEXT_SETTINGS)
|
||||
def config() -> None:
|
||||
"""查看或修改本地配置"""
|
||||
|
||||
|
||||
@config.command("path", context_settings=CONTEXT_SETTINGS)
|
||||
def config_path() -> None:
|
||||
"""显示配置路径"""
|
||||
click.echo(f"Config Dir: {settings.CONFIG_PATH}")
|
||||
click.echo(f"Env File: {settings.CONFIG_PATH / 'app.env'}")
|
||||
click.echo(f"Frontend Dir: {FRONTEND_DIR}")
|
||||
|
||||
|
||||
@config.command("list", context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--show-secrets", is_flag=True, help="显示敏感配置原文")
|
||||
def config_list(show_secrets: bool) -> None:
|
||||
"""列出当前配置"""
|
||||
values = settings.model_dump()
|
||||
for key in sorted(values):
|
||||
click.echo(f"{key}={_format_value(_mask_value(key, values[key], show_secrets))}")
|
||||
|
||||
|
||||
@config.command("get", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("key")
|
||||
def config_get(key: str) -> None:
|
||||
"""读取单个配置项"""
|
||||
if key not in Settings.model_fields and not hasattr(settings, key):
|
||||
raise click.ClickException(f"配置项不存在:{key}")
|
||||
click.echo(_format_value(getattr(settings, key)))
|
||||
|
||||
|
||||
@config.command("set", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def config_set(key: str, value: str) -> None:
|
||||
"""写入单个配置项"""
|
||||
result, message = settings.update_setting(key, value)
|
||||
if result is False:
|
||||
raise click.ClickException(message or f"配置项更新失败:{key}")
|
||||
if result is None:
|
||||
click.echo(f"{key} 未发生变化")
|
||||
return
|
||||
|
||||
click.echo(f"{key} 已更新")
|
||||
if message:
|
||||
click.echo(message)
|
||||
|
||||
backend_state, _, _, _ = _managed_backend_status()
|
||||
frontend_state, _, _, _ = _managed_frontend_status()
|
||||
if backend_state in {"running", "starting", "running-unmanaged"} or frontend_state in {"running", "starting", "running-unmanaged"}:
|
||||
click.echo("检测到服务正在运行,新配置将在重启前后端服务后生效")
|
||||
|
||||
|
||||
@config.command("keys", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("pattern", required=False)
|
||||
@click.option("--show-current", is_flag=True, help="同时显示当前值")
|
||||
@click.option("--show-secrets", is_flag=True, help="显示敏感配置原文")
|
||||
def config_keys(pattern: Optional[str], show_current: bool, show_secrets: bool) -> None:
|
||||
"""列出所有可配置项及类型"""
|
||||
rows = []
|
||||
for key, field in Settings.model_fields.items():
|
||||
if pattern and pattern.lower() not in key.lower():
|
||||
continue
|
||||
default_value = _field_default(field)
|
||||
current_value = getattr(settings, key, default_value)
|
||||
rows.append(
|
||||
(
|
||||
key,
|
||||
_annotation_name(field.annotation),
|
||||
_format_value(_mask_value(key, default_value, show_secrets)),
|
||||
_format_value(_mask_value(key, current_value, show_secrets)),
|
||||
)
|
||||
)
|
||||
|
||||
if not rows:
|
||||
raise click.ClickException("未找到匹配的配置项")
|
||||
|
||||
key_width = max(len(row[0]) for row in rows)
|
||||
type_width = max(len(row[1]) for row in rows)
|
||||
for key, type_name, default_value, current_value in rows:
|
||||
line = f"{key.ljust(key_width)} {type_name.ljust(type_width)} default={default_value}"
|
||||
if show_current:
|
||||
line = f"{line} current={current_value}"
|
||||
click.echo(line)
|
||||
|
||||
|
||||
@config.command("describe", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("key")
|
||||
@click.option("--show-secrets", is_flag=True, help="显示敏感配置原文")
|
||||
def config_describe(key: str, show_secrets: bool) -> None:
|
||||
"""显示单个配置项的类型、默认值和当前值"""
|
||||
field = Settings.model_fields.get(key)
|
||||
if not field:
|
||||
raise click.ClickException(f"配置项不存在:{key}")
|
||||
|
||||
default_value = _field_default(field)
|
||||
current_value = getattr(settings, key, default_value)
|
||||
click.echo(f"Key: {key}")
|
||||
click.echo(f"Type: {_annotation_name(field.annotation)}")
|
||||
click.echo(f"Default: {_format_value(_mask_value(key, default_value, show_secrets))}")
|
||||
click.echo(f"Current: {_format_value(_mask_value(key, current_value, show_secrets))}")
|
||||
click.echo(f"Env File: {settings.CONFIG_PATH / 'app.env'}")
|
||||
|
||||
|
||||
@cli.group(context_settings=CONTEXT_SETTINGS)
|
||||
def tool() -> None:
|
||||
"""通过本地后端服务调用 MoviePilot 工具"""
|
||||
|
||||
|
||||
@tool.command("list", context_settings=CONTEXT_SETTINGS)
|
||||
def tool_list() -> None:
|
||||
"""列出所有可用工具"""
|
||||
tools = _load_tools(runtime=_backend_runtime())
|
||||
for item in sorted(tools, key=lambda entry: entry.get("name", "")):
|
||||
click.echo(item.get("name"))
|
||||
|
||||
|
||||
@tool.command("show", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("tool_name")
|
||||
def tool_show(tool_name: str) -> None:
|
||||
"""显示工具详情和参数"""
|
||||
tool_info = _load_tool(tool_name, runtime=_backend_runtime())
|
||||
_format_tool_detail(tool_info)
|
||||
|
||||
|
||||
@tool.command("run", context_settings={**CONTEXT_SETTINGS, "ignore_unknown_options": True})
|
||||
@click.argument("tool_name")
|
||||
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
||||
def tool_run(tool_name: str, args: tuple[str, ...]) -> None:
|
||||
"""运行指定工具"""
|
||||
arguments = {"explanation": "CLI invocation"}
|
||||
arguments.update(_parse_key_value_pairs(args))
|
||||
result = _call_tool(tool_name, arguments, runtime=_backend_runtime())
|
||||
if isinstance(result, (dict, list)):
|
||||
_print_json(result)
|
||||
else:
|
||||
click.echo(result)
|
||||
|
||||
|
||||
@cli.group(context_settings=CONTEXT_SETTINGS)
|
||||
def scheduler() -> None:
|
||||
"""查看或执行本地调度任务"""
|
||||
|
||||
|
||||
@scheduler.command("list", context_settings=CONTEXT_SETTINGS)
|
||||
def scheduler_list() -> None:
|
||||
"""列出调度任务"""
|
||||
result = _call_tool(
|
||||
"query_schedulers",
|
||||
{"explanation": "List scheduler jobs from local CLI"},
|
||||
runtime=_backend_runtime(),
|
||||
)
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
click.echo(f"{item.get('id')}\t{item.get('status')}\t{item.get('next_run')}\t{item.get('name')}")
|
||||
return
|
||||
click.echo(result)
|
||||
|
||||
|
||||
@scheduler.command("run", context_settings=CONTEXT_SETTINGS)
|
||||
@click.argument("job_id")
|
||||
def scheduler_run(job_id: str) -> None:
|
||||
"""立即执行某个调度任务"""
|
||||
result = _call_tool(
|
||||
"run_scheduler",
|
||||
{
|
||||
"explanation": "Run a scheduler job from local CLI",
|
||||
"job_id": job_id,
|
||||
},
|
||||
runtime=_backend_runtime(),
|
||||
)
|
||||
if isinstance(result, (dict, list)):
|
||||
_print_json(result)
|
||||
else:
|
||||
click.echo(result)
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
def version() -> None:
|
||||
"""显示版本信息"""
|
||||
click.echo(f"MoviePilot CLI: {APP_VERSION}")
|
||||
|
||||
healthy_backend, payload = _backend_health(runtime=_backend_runtime())
|
||||
if healthy_backend:
|
||||
data = (payload or {}).get("data") or {}
|
||||
click.echo(f"Backend Service: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
else:
|
||||
click.echo("Backend Service: not running")
|
||||
|
||||
healthy_frontend, frontend_payload = _frontend_health(runtime=_frontend_runtime())
|
||||
if healthy_frontend:
|
||||
click.echo(f"Frontend Service: {(frontend_payload or {}).get('version') or 'unknown'}")
|
||||
else:
|
||||
click.echo("Frontend Service: not running")
|
||||
|
||||
click.echo(f"Frontend Installed: {_installed_frontend_version() or 'not installed'}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli(prog_name="moviepilot")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
321
docs/cli.md
Normal file
321
docs/cli.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# MoviePilot CLI
|
||||
|
||||
`moviepilot` 是 MoviePilot 本地源码模式的一体化入口,用于安装后端、安装前端 release、同步资源文件、初始化配置,以及统一管理前后端服务。
|
||||
|
||||
## 一键安装
|
||||
|
||||
直接从仓库读取脚本并执行:
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
|
||||
- 检测操作系统
|
||||
- 检查 `git`、`curl`、`Python 3.12+`
|
||||
- 克隆 `MoviePilot`
|
||||
- 安装后端依赖
|
||||
- 下载 `MoviePilot-Frontend` 最新 release 的 `dist.zip`
|
||||
- 下载 `MoviePilot-Resources` 主分支资源
|
||||
- 将 `resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 下载本地 Node 运行时并安装前端运行依赖
|
||||
- 创建全局 `moviepilot` 命令
|
||||
- 默认启动前后端服务
|
||||
|
||||
## 目录说明
|
||||
|
||||
本地安装完成后,主要运行目录如下:
|
||||
|
||||
- 后端代码:仓库根目录
|
||||
- 前端静态文件:`public/`
|
||||
- 前端本地 Node 运行时:`.runtime/node/`
|
||||
- 后端日志:`config/logs/moviepilot.log`
|
||||
- 后端启动日志:`config/logs/moviepilot.stdout.log`
|
||||
- 前端启动日志:`config/logs/moviepilot.frontend.stdout.log`
|
||||
|
||||
## 帮助与发现
|
||||
|
||||
根帮助:
|
||||
|
||||
```shell
|
||||
moviepilot --help
|
||||
moviepilot help
|
||||
moviepilot commands
|
||||
```
|
||||
|
||||
分级帮助:
|
||||
|
||||
```shell
|
||||
moviepilot help install
|
||||
moviepilot help init
|
||||
moviepilot help setup
|
||||
moviepilot help config
|
||||
moviepilot help config set
|
||||
moviepilot help tool
|
||||
moviepilot help scheduler
|
||||
```
|
||||
|
||||
配置项清单与说明:
|
||||
|
||||
```shell
|
||||
moviepilot config keys
|
||||
moviepilot config keys API
|
||||
moviepilot config describe API_TOKEN
|
||||
```
|
||||
|
||||
动态工具清单与参数说明:
|
||||
|
||||
```shell
|
||||
moviepilot tool list
|
||||
moviepilot tool show <tool_name>
|
||||
```
|
||||
|
||||
## 完整命令清单
|
||||
|
||||
```text
|
||||
moviepilot install deps
|
||||
moviepilot install frontend
|
||||
moviepilot install resources
|
||||
moviepilot init
|
||||
moviepilot setup
|
||||
moviepilot start
|
||||
moviepilot stop
|
||||
moviepilot restart
|
||||
moviepilot status
|
||||
moviepilot logs
|
||||
moviepilot version
|
||||
moviepilot config path
|
||||
moviepilot config list
|
||||
moviepilot config get
|
||||
moviepilot config set
|
||||
moviepilot config keys
|
||||
moviepilot config describe
|
||||
moviepilot tool list
|
||||
moviepilot tool show
|
||||
moviepilot tool run
|
||||
moviepilot scheduler list
|
||||
moviepilot scheduler run
|
||||
moviepilot help
|
||||
moviepilot commands
|
||||
```
|
||||
|
||||
## 安装命令
|
||||
|
||||
安装后端依赖:
|
||||
|
||||
```shell
|
||||
moviepilot install deps
|
||||
moviepilot install deps --python python3.12
|
||||
moviepilot install deps --venv /path/to/venv
|
||||
moviepilot install deps --recreate
|
||||
```
|
||||
|
||||
安装前端 release:
|
||||
|
||||
```shell
|
||||
moviepilot install frontend
|
||||
moviepilot install frontend --version latest
|
||||
moviepilot install frontend --version v2.9.31
|
||||
moviepilot install frontend --node-version 20.12.1
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 默认下载 `MoviePilot-Frontend` 最新 release 的 `dist.zip`
|
||||
- 会自动安装本地 Node 运行时
|
||||
- 会自动安装 `service.js` 所需的运行依赖
|
||||
|
||||
安装资源文件:
|
||||
|
||||
```shell
|
||||
moviepilot install resources
|
||||
moviepilot install resources --resources-repo /path/to/MoviePilot-Resources
|
||||
moviepilot install resources --resource-dir /path/to/resources.v2
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 默认直接从 GitHub 下载 `MoviePilot-Resources` 主分支压缩包
|
||||
- 会将 `resources.v2/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 这一步和 Docker 构建流程保持一致
|
||||
|
||||
## 初始化命令
|
||||
|
||||
初始化本地配置:
|
||||
|
||||
```shell
|
||||
moviepilot init
|
||||
moviepilot init --wizard
|
||||
moviepilot init --skip-resources
|
||||
moviepilot init --force-token
|
||||
```
|
||||
|
||||
一体化安装:
|
||||
|
||||
```shell
|
||||
moviepilot setup
|
||||
moviepilot setup --wizard
|
||||
moviepilot setup --frontend-version latest
|
||||
moviepilot setup --node-version 20.12.1
|
||||
moviepilot setup --skip-resources
|
||||
moviepilot setup --recreate
|
||||
```
|
||||
|
||||
`moviepilot setup` 会串行执行:
|
||||
|
||||
1. 安装后端依赖
|
||||
2. 下载并安装前端 release
|
||||
3. 下载并同步资源文件
|
||||
4. 初始化本地配置
|
||||
|
||||
`--wizard` 会进入交互式初始化向导,支持配置:
|
||||
|
||||
- `API_TOKEN`
|
||||
- 默认下载目录与媒体库目录
|
||||
- 下载器
|
||||
- 媒体服务器
|
||||
- 消息通知渠道
|
||||
|
||||
## 服务管理命令
|
||||
|
||||
`moviepilot start/stop/restart/status` 现在统一管理前后端。
|
||||
|
||||
启动、停止、重启与状态:
|
||||
|
||||
```shell
|
||||
moviepilot start
|
||||
moviepilot start --timeout 60
|
||||
moviepilot stop
|
||||
moviepilot stop --timeout 30 --force
|
||||
moviepilot restart
|
||||
moviepilot restart --start-timeout 60 --stop-timeout 30
|
||||
moviepilot status
|
||||
moviepilot version
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `start` 会先启动后端,再启动前端
|
||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||
- 后端默认监听 `PORT`,默认值 `3001`
|
||||
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
||||
|
||||
日志:
|
||||
|
||||
```shell
|
||||
moviepilot logs
|
||||
moviepilot logs --lines 100
|
||||
moviepilot logs --stdio
|
||||
moviepilot logs --frontend
|
||||
moviepilot logs --follow
|
||||
moviepilot logs --frontend --follow
|
||||
moviepilot logs --stdio --follow
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 默认 `logs` 查看后端应用日志
|
||||
- `--stdio` 查看后端启动标准输出
|
||||
- `--frontend` 查看前端启动标准输出
|
||||
|
||||
## 配置命令
|
||||
|
||||
查看配置路径:
|
||||
|
||||
```shell
|
||||
moviepilot config path
|
||||
```
|
||||
|
||||
查看当前配置:
|
||||
|
||||
```shell
|
||||
moviepilot config list
|
||||
moviepilot config list --show-secrets
|
||||
```
|
||||
|
||||
读取和写入单个配置:
|
||||
|
||||
```shell
|
||||
moviepilot config get PORT
|
||||
moviepilot config set PORT 3001
|
||||
moviepilot config set NGINX_PORT 3000
|
||||
moviepilot config set API_TOKEN your-token-here
|
||||
```
|
||||
|
||||
查看所有可配置项:
|
||||
|
||||
```shell
|
||||
moviepilot config keys
|
||||
moviepilot config keys DB_
|
||||
moviepilot config keys --show-current
|
||||
moviepilot config keys --show-current --show-secrets
|
||||
moviepilot config describe PORT
|
||||
moviepilot config describe API_TOKEN --show-secrets
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `config list` 显示当前配置值
|
||||
- `config keys` 显示配置项名称、类型和默认值
|
||||
- `config describe` 显示单个配置项的类型、默认值、当前值与配置文件位置
|
||||
- 如果前后端正在运行,更新配置后需要 `moviepilot restart`
|
||||
|
||||
## 工具命令
|
||||
|
||||
工具命令依赖后端已启动,并且本地配置中存在有效的 `API_TOKEN`。
|
||||
|
||||
列出工具:
|
||||
|
||||
```shell
|
||||
moviepilot tool list
|
||||
```
|
||||
|
||||
查看工具参数:
|
||||
|
||||
```shell
|
||||
moviepilot tool show search_media
|
||||
```
|
||||
|
||||
调用工具:
|
||||
|
||||
```shell
|
||||
moviepilot tool run search_media title="Inception" media_type=movie
|
||||
moviepilot tool run query_schedulers
|
||||
```
|
||||
|
||||
`tool list` 和 `tool show` 是查看“当前后端实际暴露的全部工具与参数”的推荐方式。
|
||||
|
||||
## 调度命令
|
||||
|
||||
查看调度任务:
|
||||
|
||||
```shell
|
||||
moviepilot scheduler list
|
||||
```
|
||||
|
||||
立即执行调度任务:
|
||||
|
||||
```shell
|
||||
moviepilot scheduler run subscribe_search
|
||||
```
|
||||
|
||||
## 推荐流程
|
||||
|
||||
首次安装:
|
||||
|
||||
```shell
|
||||
moviepilot setup --wizard
|
||||
moviepilot start
|
||||
moviepilot status
|
||||
```
|
||||
|
||||
日常维护:
|
||||
|
||||
```shell
|
||||
moviepilot status
|
||||
moviepilot logs --frontend
|
||||
moviepilot logs --stdio
|
||||
moviepilot config keys
|
||||
moviepilot tool list
|
||||
```
|
||||
280
moviepilot
Executable file
280
moviepilot
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
show_help() {
|
||||
cat <<'EOF'
|
||||
Usage: moviepilot [BOOTSTRAP COMMAND] | [RUNTIME COMMAND]
|
||||
moviepilot help [COMMAND ...]
|
||||
moviepilot commands
|
||||
|
||||
Bootstrap Commands:
|
||||
moviepilot install deps [--python PYTHON] [--venv PATH] [--recreate]
|
||||
moviepilot install frontend [--version latest] [--node-version 20.12.1]
|
||||
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH]
|
||||
moviepilot init [--skip-resources] [--force-token] [--wizard]
|
||||
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard]
|
||||
|
||||
Runtime Commands:
|
||||
moviepilot start|stop|restart|status|logs|version
|
||||
moviepilot config ...
|
||||
moviepilot tool ...
|
||||
moviepilot scheduler ...
|
||||
|
||||
Discovery Commands:
|
||||
moviepilot help
|
||||
moviepilot help config
|
||||
moviepilot help install
|
||||
moviepilot commands
|
||||
|
||||
Examples:
|
||||
moviepilot install deps
|
||||
moviepilot install frontend
|
||||
moviepilot install resources
|
||||
moviepilot setup --wizard
|
||||
moviepilot help config
|
||||
moviepilot config keys
|
||||
moviepilot start
|
||||
moviepilot tool list
|
||||
EOF
|
||||
}
|
||||
|
||||
show_commands() {
|
||||
cat <<'EOF'
|
||||
Bootstrap Commands
|
||||
install deps
|
||||
install frontend
|
||||
install resources
|
||||
init
|
||||
setup
|
||||
|
||||
Runtime Commands
|
||||
start
|
||||
stop
|
||||
restart
|
||||
status
|
||||
logs
|
||||
version
|
||||
config path
|
||||
config list
|
||||
config get
|
||||
config set
|
||||
config keys
|
||||
config describe
|
||||
tool list
|
||||
tool show
|
||||
tool run
|
||||
scheduler list
|
||||
scheduler run
|
||||
|
||||
Discovery Commands
|
||||
help
|
||||
commands
|
||||
EOF
|
||||
}
|
||||
|
||||
show_install_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
moviepilot install deps [OPTIONS]
|
||||
moviepilot install frontend [OPTIONS]
|
||||
moviepilot install resources [OPTIONS]
|
||||
|
||||
Options:
|
||||
deps:
|
||||
--python PYTHON 用于创建虚拟环境的 Python 解释器
|
||||
--venv PATH 虚拟环境目录,默认 ./venv
|
||||
--recreate 删除并重建虚拟环境
|
||||
|
||||
frontend:
|
||||
--version TAG 前端版本,默认 latest
|
||||
--node-version VER 本地 Node 运行时版本,默认 20.12.1
|
||||
|
||||
resources:
|
||||
--resources-repo PATH 本地 MoviePilot-Resources 仓库路径
|
||||
--resource-dir PATH 直接指定 resources.v2 目录
|
||||
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
show_init_help() {
|
||||
cat <<'EOF'
|
||||
Usage: moviepilot init [OPTIONS]
|
||||
|
||||
Options:
|
||||
--skip-resources 跳过资源同步
|
||||
--force-token 强制重置 API_TOKEN
|
||||
--wizard 启动交互式初始化向导
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
show_setup_help() {
|
||||
cat <<'EOF'
|
||||
Usage: moviepilot setup [OPTIONS]
|
||||
|
||||
Options:
|
||||
--python PYTHON 用于创建虚拟环境的 Python 解释器
|
||||
--venv PATH 虚拟环境目录,默认 ./venv
|
||||
--recreate 删除并重建虚拟环境
|
||||
--frontend-version TAG 前端版本,默认 latest
|
||||
--node-version VER 本地 Node 运行时版本,默认 20.12.1
|
||||
--skip-resources 跳过资源同步
|
||||
--force-token 强制重置 API_TOKEN
|
||||
--wizard 安装完成后启动交互式初始化向导
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
find_system_python() {
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
command -v python3
|
||||
return 0
|
||||
fi
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
command -v python
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_runtime_cli() {
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "未找到项目虚拟环境,请先执行 moviepilot install deps 或 moviepilot setup" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$VENV_PYTHON" -m app.cli "$@"
|
||||
}
|
||||
|
||||
show_command_help() {
|
||||
case "${1:-}" in
|
||||
""|-h|--help|help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
install)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
""|deps|-h|--help)
|
||||
show_install_help
|
||||
exit 0
|
||||
;;
|
||||
frontend)
|
||||
show_install_help
|
||||
exit 0
|
||||
;;
|
||||
resources)
|
||||
show_install_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "仅支持:moviepilot help install、moviepilot help install deps、moviepilot help install frontend、moviepilot help install resources" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
init)
|
||||
show_init_help
|
||||
exit 0
|
||||
;;
|
||||
setup)
|
||||
show_setup_help
|
||||
exit 0
|
||||
;;
|
||||
commands)
|
||||
show_commands
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
run_runtime_cli "$@" --help
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -L "$SOURCE" ]; do
|
||||
SOURCE_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
||||
SOURCE_TARGET="$(readlink "$SOURCE")"
|
||||
if [[ "$SOURCE_TARGET" != /* ]]; then
|
||||
SOURCE="$SOURCE_DIR/$SOURCE_TARGET"
|
||||
else
|
||||
SOURCE="$SOURCE_TARGET"
|
||||
fi
|
||||
done
|
||||
|
||||
ROOT="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
||||
VENV_PYTHON="$ROOT/venv/bin/python"
|
||||
SETUP_SCRIPT="$ROOT/scripts/local_setup.py"
|
||||
|
||||
BOOTSTRAP_PYTHON=""
|
||||
if [ -x "$VENV_PYTHON" ]; then
|
||||
BOOTSTRAP_PYTHON="$VENV_PYTHON"
|
||||
else
|
||||
BOOTSTRAP_PYTHON="$(find_system_python || true)"
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
case "${1:-}" in
|
||||
""|-h|--help|help)
|
||||
if [ "${1:-}" = "help" ]; then
|
||||
shift
|
||||
show_command_help "$@"
|
||||
fi
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
commands)
|
||||
show_commands
|
||||
exit 0
|
||||
;;
|
||||
install)
|
||||
shift
|
||||
if [ -z "$BOOTSTRAP_PYTHON" ]; then
|
||||
echo "未找到可用的 Python 解释器,请先安装 Python 3" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "${1:-}" in
|
||||
deps)
|
||||
shift
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-deps "$@"
|
||||
;;
|
||||
frontend)
|
||||
shift
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-frontend "$@"
|
||||
;;
|
||||
resources)
|
||||
shift
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-resources "$@"
|
||||
;;
|
||||
*)
|
||||
echo "支持的命令:moviepilot install deps|frontend|resources" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
init)
|
||||
shift
|
||||
if [ -z "$BOOTSTRAP_PYTHON" ]; then
|
||||
echo "未找到可用的 Python 解释器,请先安装 Python 3" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" init "$@"
|
||||
;;
|
||||
setup)
|
||||
shift
|
||||
if [ -z "$BOOTSTRAP_PYTHON" ]; then
|
||||
echo "未找到可用的 Python 解释器,请先安装 Python 3" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "未找到项目虚拟环境,请先执行 moviepilot install deps 或 moviepilot setup" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$VENV_PYTHON" -m app.cli "$@"
|
||||
314
scripts/bootstrap-local.sh
Executable file
314
scripts/bootstrap-local.sh
Executable file
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/jxxghp/MoviePilot.git"
|
||||
WORKDIR="$PWD"
|
||||
APP_DIR_NAME="MoviePilot"
|
||||
LINK_CLI="true"
|
||||
LINK_PATH=""
|
||||
RUN_WIZARD="true"
|
||||
START_AFTER_INSTALL="true"
|
||||
NON_INTERACTIVE="false"
|
||||
OS_NAME="Unknown"
|
||||
PYTHON_BIN=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Options:
|
||||
--workdir PATH 克隆与安装的目标目录,默认当前目录
|
||||
--app-dir NAME MoviePilot 目录名,默认 ${APP_DIR_NAME}
|
||||
--repo-url URL 主项目仓库地址
|
||||
--link-path PATH 全局 moviepilot 软链接位置
|
||||
--no-link-cli 安装完成后不创建全局 moviepilot 命令
|
||||
--no-wizard 跳过 moviepilot setup 的交互式初始化向导
|
||||
--no-start 安装完成后不自动启动服务
|
||||
--non-interactive 非交互模式,直接使用传入参数
|
||||
-h, --help 显示帮助
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --workdir ~/Projects
|
||||
$(basename "$0") --non-interactive --workdir ~/Projects --no-start
|
||||
EOF
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
local uname_s
|
||||
uname_s="$(uname -s)"
|
||||
|
||||
case "$uname_s" in
|
||||
Darwin)
|
||||
OS_NAME="macOS"
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
LINK_PATH="$(brew --prefix)/bin/moviepilot"
|
||||
else
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
OS_NAME="Linux (WSL)"
|
||||
else
|
||||
OS_NAME="Linux"
|
||||
fi
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
OS_NAME="Windows"
|
||||
;;
|
||||
*)
|
||||
OS_NAME="$uname_s"
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
find_python() {
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
command -v python3
|
||||
return 0
|
||||
fi
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
command -v python
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
python_version_ok() {
|
||||
local python_bin="$1"
|
||||
"$python_bin" - <<'PY' >/dev/null 2>&1
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3, 12) else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
python_install_hint() {
|
||||
case "$OS_NAME" in
|
||||
macOS)
|
||||
echo "请先安装 Git、curl 和 Python 3.12,例如:brew install git curl python@3.12" >&2
|
||||
;;
|
||||
Linux*)
|
||||
echo "请先安装 Git、curl 和 Python 3.12,并确保包含 venv 模块。" >&2
|
||||
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.12 python3.12-venv" >&2
|
||||
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.12" >&2
|
||||
;;
|
||||
Windows)
|
||||
echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2
|
||||
;;
|
||||
*)
|
||||
echo "请先安装 Git、curl 和 Python 3.12。" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_prereqs() {
|
||||
if [[ "$OS_NAME" == "Windows" ]]; then
|
||||
echo "检测到当前环境为 Windows shell,建议改用 WSL、Linux 或 macOS 终端运行。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "未找到 git。" >&2
|
||||
python_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "未找到 curl。" >&2
|
||||
python_install_hint
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON_BIN="$(find_python || true)"
|
||||
if [[ -z "$PYTHON_BIN" ]] || ! python_version_ok "$PYTHON_BIN"; then
|
||||
echo "未找到可用的 Python 3.12+ 解释器。" >&2
|
||||
python_install_hint
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local label="$1"
|
||||
local default_value="${2:-}"
|
||||
local answer=""
|
||||
|
||||
if [[ -n "$default_value" ]]; then
|
||||
read -r -p "$label [$default_value]: " answer || true
|
||||
if [[ -z "$answer" ]]; then
|
||||
answer="$default_value"
|
||||
fi
|
||||
else
|
||||
read -r -p "$label: " answer || true
|
||||
fi
|
||||
|
||||
printf '%s\n' "$answer"
|
||||
}
|
||||
|
||||
prompt_yes_no() {
|
||||
local label="$1"
|
||||
local default_value="${2:-y}"
|
||||
local answer=""
|
||||
local prompt="[y/N]"
|
||||
|
||||
if [[ "$default_value" == "y" ]]; then
|
||||
prompt="[Y/n]"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
read -r -p "$label $prompt: " answer || true
|
||||
answer="${answer,,}"
|
||||
if [[ -z "$answer" ]]; then
|
||||
answer="$default_value"
|
||||
fi
|
||||
case "$answer" in
|
||||
y|yes) return 0 ;;
|
||||
n|no) return 1 ;;
|
||||
esac
|
||||
echo "请输入 y 或 n。"
|
||||
done
|
||||
}
|
||||
|
||||
run_interactive_guide() {
|
||||
echo "==> 当前系统: $OS_NAME"
|
||||
echo "==> 将自动拉取 MoviePilot,并下载前端 release、资源文件与本地 Node 运行时"
|
||||
|
||||
WORKDIR="$(prompt_text "安装目录" "$WORKDIR")"
|
||||
APP_DIR_NAME="$(prompt_text "主项目目录名" "$APP_DIR_NAME")"
|
||||
|
||||
if prompt_yes_no "安装过程中进入 MoviePilot 初始化向导" "y"; then
|
||||
RUN_WIZARD="true"
|
||||
else
|
||||
RUN_WIZARD="false"
|
||||
fi
|
||||
|
||||
if prompt_yes_no "安装完成后立即启动前后端服务" "y"; then
|
||||
START_AFTER_INSTALL="true"
|
||||
else
|
||||
START_AFTER_INSTALL="false"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_link_path() {
|
||||
if [[ "$LINK_CLI" != "true" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "$LINK_PATH" ]]; then
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
fi
|
||||
|
||||
local link_dir
|
||||
link_dir="$(dirname "$LINK_PATH")"
|
||||
if mkdir -p "$link_dir" 2>/dev/null && [[ -w "$link_dir" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
LINK_PATH="$HOME/.local/bin/moviepilot"
|
||||
mkdir -p "$(dirname "$LINK_PATH")"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--workdir)
|
||||
WORKDIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--app-dir)
|
||||
APP_DIR_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repo-url)
|
||||
REPO_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--link-path)
|
||||
LINK_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-link-cli)
|
||||
LINK_CLI="false"
|
||||
shift
|
||||
;;
|
||||
--no-wizard)
|
||||
RUN_WIZARD="false"
|
||||
shift
|
||||
;;
|
||||
--no-start)
|
||||
START_AFTER_INSTALL="false"
|
||||
shift
|
||||
;;
|
||||
--non-interactive)
|
||||
NON_INTERACTIVE="true"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
detect_os
|
||||
require_prereqs
|
||||
ensure_link_path
|
||||
|
||||
if [[ "$NON_INTERACTIVE" != "true" && -t 0 && -t 1 ]]; then
|
||||
run_interactive_guide
|
||||
ensure_link_path
|
||||
fi
|
||||
|
||||
mkdir -p "$WORKDIR"
|
||||
WORKDIR="$(cd "$WORKDIR" && pwd)"
|
||||
APP_DIR="$WORKDIR/$APP_DIR_NAME"
|
||||
|
||||
if [[ ! -d "$APP_DIR/.git" ]]; then
|
||||
echo "==> 克隆 MoviePilot 到 $APP_DIR"
|
||||
git clone "$REPO_URL" "$APP_DIR"
|
||||
else
|
||||
echo "==> 复用已有 MoviePilot 仓库: $APP_DIR"
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
echo "==> 执行本地环境安装与初始化"
|
||||
SETUP_ARGS=(setup)
|
||||
if [[ "$RUN_WIZARD" == "true" ]]; then
|
||||
SETUP_ARGS+=(--wizard)
|
||||
fi
|
||||
./moviepilot "${SETUP_ARGS[@]}"
|
||||
|
||||
if [[ "$LINK_CLI" == "true" ]]; then
|
||||
echo "==> 创建全局 moviepilot 命令到 $LINK_PATH"
|
||||
ln -sf "$APP_DIR/moviepilot" "$LINK_PATH"
|
||||
fi
|
||||
|
||||
if [[ "$START_AFTER_INSTALL" == "true" ]]; then
|
||||
echo "==> 启动 MoviePilot 前后端服务"
|
||||
./moviepilot start
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
==> 安装完成
|
||||
|
||||
系统环境: $OS_NAME
|
||||
项目目录: $APP_DIR
|
||||
Python 解释器: $PYTHON_BIN
|
||||
CLI 命令: ${LINK_CLI:-false}
|
||||
CLI 路径: ${LINK_PATH:-未创建}
|
||||
|
||||
使用方式:
|
||||
moviepilot status
|
||||
moviepilot logs --frontend
|
||||
moviepilot logs --stdio
|
||||
|
||||
完整 CLI 文档:
|
||||
$APP_DIR/docs/cli.md
|
||||
EOF
|
||||
1004
scripts/local_setup.py
Normal file
1004
scripts/local_setup.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user