add full-stack local cli install flow

This commit is contained in:
jxxghp
2026-04-16 09:51:00 +08:00
parent 04c2a1eb18
commit 9a2a241a30
7 changed files with 2924 additions and 26 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -16,17 +16,31 @@
发布频道https://t.me/moviepilot_channel
## 主要特性
- 前后端分离基于FastApi + Vue3。
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
- 重新设计了用户界面,更加美观易用。
## 安装使用
官方Wikihttps://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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff