diff --git a/.gitignore b/.gitignore index bc4842fc..34e62a92 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 94a60b25..30af9676 100644 --- a/README.md +++ b/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` 目录下开发插件代码 +插件开发说明: ## 相关项目 diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 00000000..addfe327 --- /dev/null +++ b/app/cli.py @@ -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() diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..d4db4fdb --- /dev/null +++ b/docs/cli.md @@ -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 +``` + +## 完整命令清单 + +```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 +``` diff --git a/moviepilot b/moviepilot new file mode 100755 index 00000000..995291f2 --- /dev/null +++ b/moviepilot @@ -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 "$@" diff --git a/scripts/bootstrap-local.sh b/scripts/bootstrap-local.sh new file mode 100755 index 00000000..3bed0f75 --- /dev/null +++ b/scripts/bootstrap-local.sh @@ -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 </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 < 安装完成 + +系统环境: $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 diff --git a/scripts/local_setup.py b/scripts/local_setup.py new file mode 100644 index 00000000..86b7de16 --- /dev/null +++ b/scripts/local_setup.py @@ -0,0 +1,1004 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import platform +import secrets +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, Optional + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_DIR = ROOT / "config" +LOG_DIR = CONFIG_DIR / "logs" +CACHE_DIR = CONFIG_DIR / "cache" +TEMP_DIR = CONFIG_DIR / "temp" +COOKIE_DIR = CONFIG_DIR / "cookies" +HELPER_DIR = ROOT / "app" / "helper" +ENV_FILE = CONFIG_DIR / "app.env" +PUBLIC_DIR = ROOT / "public" +RUNTIME_DIR = ROOT / ".runtime" +NODE_DIR = RUNTIME_DIR / "node" + +DEFAULT_NODE_VERSION = "20.12.1" +FRONTEND_LATEST_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" +FRONTEND_TAG_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/tags/{tag}" +RESOURCES_MAIN_ZIP = "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" +RUNTIME_PACKAGE = { + "name": "moviepilot-frontend-runtime", + "private": True, + "license": "UNLICENSED", + "dependencies": { + "express": "^4.18.2", + "express-http-proxy": "^2.0.0", + }, +} +NOTIFICATION_SWITCH_TYPES = [ + "资源下载", + "整理入库", + "订阅", + "站点", + "媒体服务器", + "手动处理", + "插件", + "智能体", + "其它", +] + + +def print_step(message: str) -> None: + print(f"==> {message}") + + +def run(command: list[str], cwd: Optional[Path] = None) -> None: + pretty = " ".join(command) + print(f"+ {pretty}") + subprocess.run(command, cwd=str(cwd or ROOT), check=True) + + +def capture(command: list[str], cwd: Optional[Path] = None) -> str: + return subprocess.check_output(command, cwd=str(cwd or ROOT), text=True).strip() + + +def command_exists(name: str) -> bool: + return shutil.which(name) is not None + + +def get_venv_python(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def ensure_supported_python(python_bin: str) -> None: + version_json = capture([python_bin, "-c", "import json, sys; print(json.dumps(list(sys.version_info[:3])))"]) + version = tuple(json.loads(version_json)) + if version < (3, 12, 0): + raise RuntimeError( + f"MoviePilot 本地安装需要 Python 3.12 或更高版本,当前解释器为 {python_bin} " + f"({version[0]}.{version[1]}.{version[2]})" + ) + + +def ensure_local_dirs() -> None: + for path in (CONFIG_DIR, LOG_DIR, CACHE_DIR, TEMP_DIR, COOKIE_DIR, RUNTIME_DIR): + path.mkdir(parents=True, exist_ok=True) + + +def _load_env_lines() -> list[str]: + if not ENV_FILE.exists(): + return [] + return ENV_FILE.read_text(encoding="utf-8").splitlines(keepends=True) + + +def read_env_value(key: str) -> Optional[str]: + for line in _load_env_lines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + continue + env_key, value = line.split("=", 1) + if env_key.strip() == key: + return value.strip().strip('"').strip("'") + return None + + +def write_env_value(key: str, value: str) -> None: + ensure_local_dirs() + lines = _load_env_lines() + new_line = f"{key}={json.dumps(str(value), ensure_ascii=False)}\n" + + for index, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + continue + env_key, _ = line.split("=", 1) + if env_key.strip() == key: + lines[index] = new_line + break + else: + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + lines.append(new_line) + + ENV_FILE.write_text("".join(lines), encoding="utf-8") + + +def ensure_api_token(force_token: bool = False, token: Optional[str] = None) -> str: + ensure_local_dirs() + current_token = read_env_value("API_TOKEN") or "" + if token is not None: + token = str(token).strip() + if len(token) < 16: + raise ValueError("API_TOKEN 长度不能少于 16 个字符") + write_env_value("API_TOKEN", token) + print_step(f"已写入 API_TOKEN 到 {ENV_FILE}") + return token + + if current_token and len(current_token) >= 16 and not force_token: + print_step("保留现有 API_TOKEN") + return current_token + + new_token = secrets.token_urlsafe(16) + write_env_value("API_TOKEN", new_token) + print_step(f"已写入 API_TOKEN 到 {ENV_FILE}") + return new_token + + +def _download_to_stdout(url: str) -> str: + headers = ["-H", "Accept: application/vnd.github+json", "-H", "User-Agent: MoviePilot-CLI"] + if command_exists("curl"): + return capture(["curl", "-fsSL", *headers, url]) + if command_exists("wget"): + return capture(["wget", "-qO-", "--header=Accept: application/vnd.github+json", "--header=User-Agent: MoviePilot-CLI", url]) + raise RuntimeError("未找到可用的下载工具,请先安装 curl 或 wget") + + +def download_file(url: str, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if command_exists("curl"): + run(["curl", "-fsSL", url, "-o", str(target)]) + return + if command_exists("wget"): + run(["wget", "-qO", str(target), url]) + return + raise RuntimeError("未找到可用的下载工具,请先安装 curl 或 wget") + + +def fetch_json(url: str) -> dict[str, Any]: + payload = _download_to_stdout(url) + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError(f"无法解析远程响应:{url}") from exc + if isinstance(data, dict) and data.get("message") and isinstance(data.get("message"), str) and "API rate limit" in data["message"]: + raise RuntimeError(f"访问 GitHub API 失败:{data['message']}") + if not isinstance(data, dict): + raise RuntimeError(f"接口返回格式异常:{url}") + return data + + +def extract_archive(archive_path: Path, target_dir: Path) -> None: + target_dir.mkdir(parents=True, exist_ok=True) + if archive_path.suffix == ".zip": + with zipfile.ZipFile(archive_path) as zip_file: + zip_file.extractall(target_dir) + return + with tarfile.open(archive_path, "r:*") as tar_file: + extract_kwargs: dict[str, Any] = {} + if sys.version_info >= (3, 12): + extract_kwargs["filter"] = "data" + tar_file.extractall(target_dir, **extract_kwargs) + + +def _remove_path(path: Path) -> None: + if not path.exists(): + return + if path.is_symlink() or path.is_file(): + path.unlink() + return + shutil.rmtree(path) + + +def _resolve_frontend_release(frontend_version: str) -> tuple[str, str]: + if frontend_version == "latest": + release = fetch_json(FRONTEND_LATEST_API) + else: + release = fetch_json(FRONTEND_TAG_API.format(tag=frontend_version)) + + tag_name = str(release.get("tag_name") or "").strip() + if not tag_name: + raise RuntimeError("未能获取前端版本号") + + for asset in release.get("assets") or []: + if asset.get("name") == "dist.zip" and asset.get("browser_download_url"): + return tag_name, str(asset["browser_download_url"]) + raise RuntimeError(f"前端版本 {tag_name} 未找到 dist.zip 发布资产") + + +def _frontend_runtime_ready(frontend_version: str) -> bool: + version_file = PUBLIC_DIR / "version.txt" + if not version_file.exists() or not (PUBLIC_DIR / "service.js").exists(): + return False + if not (PUBLIC_DIR / "node_modules" / "express").exists(): + return False + try: + return version_file.read_text(encoding="utf-8").strip() == frontend_version + except OSError: + return False + + +def _node_platform() -> tuple[str, str]: + system_name = platform.system().lower() + machine = platform.machine().lower() + + if system_name == "darwin": + if machine in {"arm64", "aarch64"}: + return "darwin-arm64", "tar.gz" + if machine in {"x86_64", "amd64"}: + return "darwin-x64", "tar.gz" + elif system_name == "linux": + if machine in {"aarch64", "arm64"}: + return "linux-arm64", "tar.xz" + if machine in {"x86_64", "amd64"}: + return "linux-x64", "tar.xz" + + raise RuntimeError(f"当前系统暂不支持自动安装本地 Node 运行时:{platform.system()} / {platform.machine()}") + + +def get_node_bin(node_dir: Path = NODE_DIR) -> Path: + if os.name == "nt": + return node_dir / "node.exe" + return node_dir / "bin" / "node" + + +def get_npm_bin(node_dir: Path = NODE_DIR) -> Path: + if os.name == "nt": + return node_dir / "npm.cmd" + return node_dir / "bin" / "npm" + + +def install_node_runtime(node_version: str) -> Path: + node_bin = get_node_bin() + if node_bin.exists(): + try: + current_version = capture([str(node_bin), "--version"]).lstrip("v") + except subprocess.CalledProcessError: + current_version = "" + if current_version == node_version: + return node_bin + _remove_path(NODE_DIR) + + platform_tag, archive_ext = _node_platform() + archive_name = f"node-v{node_version}-{platform_tag}.{archive_ext}" + download_url = f"https://nodejs.org/dist/v{node_version}/{archive_name}" + + print_step(f"下载本地 Node 运行时 v{node_version} ({platform_tag})") + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / archive_name + extract_dir = temp_path / "extract" + download_file(download_url, archive_path) + extract_archive(archive_path, extract_dir) + extracted_roots = [item for item in extract_dir.iterdir() if item.is_dir()] + if not extracted_roots: + raise RuntimeError("Node 运行时解压失败") + _remove_path(NODE_DIR) + shutil.move(str(extracted_roots[0]), str(NODE_DIR)) + + node_bin = get_node_bin() + if not node_bin.exists(): + raise RuntimeError("Node 运行时安装失败,未找到 node 可执行文件") + print_step(f"Node 运行时已安装到 {NODE_DIR}") + return node_bin + + +def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]: + version_tag, download_url = _resolve_frontend_release(frontend_version) + node_bin = install_node_runtime(node_version) + + if _frontend_runtime_ready(version_tag): + print_step(f"前端发布包已是最新版本:{version_tag}") + return {"version": version_tag, "node": str(node_bin)} + + print_step(f"下载前端发布包:{version_tag}") + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / "dist.zip" + extract_dir = temp_path / "extract" + download_file(download_url, archive_path) + extract_archive(archive_path, extract_dir) + dist_dir = extract_dir / "dist" + if not dist_dir.exists(): + raise RuntimeError("前端发布包中未找到 dist 目录") + _remove_path(PUBLIC_DIR) + shutil.move(str(dist_dir), str(PUBLIC_DIR)) + + runtime_package = dict(RUNTIME_PACKAGE) + runtime_package["version"] = version_tag + (PUBLIC_DIR / "package.json").write_text( + json.dumps(runtime_package, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + npm_bin = get_npm_bin() + if not npm_bin.exists(): + raise RuntimeError("未找到 npm 可执行文件,Node 运行时可能损坏") + + print_step("安装前端运行依赖") + run( + [ + str(npm_bin), + "install", + "--no-fund", + "--no-audit", + "--omit=dev", + ], + cwd=PUBLIC_DIR, + ) + return {"version": version_tag, "node": str(node_bin)} + + +def local_resource_status() -> bool: + return (HELPER_DIR / "user.sites.v2.bin").exists() and bool(list(HELPER_DIR.glob("sites*"))) + + +def copy_resource_files(source_dir: Path) -> list[str]: + if not source_dir.is_dir(): + raise FileNotFoundError(f"资源目录不存在:{source_dir}") + + copied: list[str] = [] + for source in sorted(source_dir.iterdir()): + if source.is_dir(): + continue + target = HELPER_DIR / source.name + shutil.copy2(source, target) + copied.append(source.name) + + if not copied: + raise RuntimeError(f"资源目录中未找到可复制文件:{source_dir}") + print_step(f"已同步资源文件到 {HELPER_DIR}") + return copied + + +def _download_resources_dir() -> Path: + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / "resources.zip" + extract_dir = temp_path / "extract" + print_step("下载资源包") + download_file(RESOURCES_MAIN_ZIP, archive_path) + extract_archive(archive_path, extract_dir) + source_dir = extract_dir / "MoviePilot-Resources-main" / "resources.v2" + if not source_dir.exists(): + raise RuntimeError("资源压缩包中未找到 resources.v2 目录") + staging_dir = temp_path / "staging" + shutil.copytree(source_dir, staging_dir) + persisted = TEMP_DIR / "resources.v2" + _remove_path(persisted) + shutil.copytree(staging_dir, persisted) + return persisted + + +def _resolve_local_resource_dir(resources_repo: Optional[Path], resource_dir: Optional[Path]) -> Optional[Path]: + if resource_dir: + resolved = resource_dir.expanduser().resolve() + if resolved.is_dir(): + return resolved + raise FileNotFoundError(f"资源目录不存在:{resolved}") + + if resources_repo: + repo_dir = resources_repo.expanduser().resolve() + candidates = [ + repo_dir / "resources.v2", + repo_dir / "resources" / "resources.v2", + repo_dir / "resources" / "v2", + repo_dir / "resources.v2", + ] + for candidate in candidates: + if candidate.is_dir(): + return candidate + raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v2 目录") + return None + + +def install_resources(resources_repo: Optional[Path], resource_dir: Optional[Path]) -> list[str]: + ensure_local_dirs() + source_dir = _resolve_local_resource_dir(resources_repo, resource_dir) + if source_dir is None: + source_dir = _download_resources_dir() + copied = copy_resource_files(source_dir) + print_step(f"资源初始化完成,共处理 {len(copied)} 个文件") + return copied + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _normalize_choice(value: str) -> str: + return value.strip().lower().replace("_", "").replace("-", "") + + +def _prompt_text( + label: str, + *, + default: Optional[str] = None, + allow_empty: bool = False, + secret: bool = False, +) -> str: + while True: + suffix = f" [{default}]" if default not in (None, "") and not secret else "" + prompt = f"{label}{suffix}: " + value = getpass.getpass(prompt) if secret else input(prompt) + value = value.strip() + + if not value and default is not None: + return str(default) + if value: + return value + if allow_empty: + return "" + print("请输入有效内容,或使用回车接受默认值。") + + +def _prompt_yes_no(label: str, default: bool = True) -> bool: + suffix = "Y/n" if default else "y/N" + while True: + raw = input(f"{label} [{suffix}]: ").strip().lower() + if not raw: + return default + if raw in {"y", "yes"}: + return True + if raw in {"n", "no"}: + return False + print("请输入 y 或 n。") + + +def _prompt_choice(label: str, choices: dict[str, str], default: str) -> str: + labels = [] + normalized_map: dict[str, str] = {} + for key, desc in choices.items(): + labels.append(f"{key}({desc})") + normalized_map[_normalize_choice(key)] = key + + while True: + raw = input(f"{label} [{'/'.join(labels)}] (默认 {default}): ").strip() + if not raw: + return default + normalized = _normalize_choice(raw) + if normalized in normalized_map: + return normalized_map[normalized] + print("请输入列表中的可选值。") + + +def _prompt_path(label: str, *, default: Path, allow_empty: bool = False) -> str: + value = _prompt_text(label, default=str(default), allow_empty=allow_empty) + if not value: + return "" + return str(Path(value).expanduser().resolve()) + + +def _collect_path_mapping() -> list[tuple[str, str]]: + if not _prompt_yes_no("是否配置下载器路径映射", default=False): + return [] + + storage_path = _prompt_path("MoviePilot 可访问的下载目录根路径", default=ROOT.parent / "downloads") + download_path = _prompt_text("下载器中对应的目录根路径", default="/downloads") + return [(storage_path, download_path)] + + +def _collect_directory_config() -> dict[str, Any]: + default_download_dir = ROOT.parent / "downloads" + default_library_dir = ROOT.parent / "media" + + print_step("目录配置") + download_path = _prompt_path("下载目录", default=default_download_dir) + library_path = _prompt_path("媒体库目录", default=default_library_dir) + transfer_type = _prompt_choice( + "整理方式", + { + "link": "硬链接", + "softlink": "软链接", + "copy": "复制", + "move": "移动", + }, + default="link", + ) + return { + "name": "默认目录", + "priority": 0, + "storage": "local", + "download_path": download_path, + "monitor_type": "downloader", + "monitor_mode": "fast", + "transfer_type": transfer_type, + "overwrite_mode": "latest", + "library_path": library_path, + "library_storage": "local", + "renaming": False, + "scraping": False, + "notify": True, + "download_type_folder": False, + "download_category_folder": False, + "library_type_folder": True, + "library_category_folder": False, + } + + +def _collect_downloader_config() -> Optional[dict[str, Any]]: + print_step("下载器配置") + downloader_type = _prompt_choice( + "选择下载器类型", + { + "skip": "跳过", + "qbittorrent": "qBittorrent", + "transmission": "Transmission", + }, + default="skip", + ) + if downloader_type == "skip": + return None + + config_name = _prompt_text("下载器名称", default=downloader_type) + if downloader_type == "qbittorrent": + host = _prompt_text("qBittorrent 地址", default="http://127.0.0.1:8080") + username = _prompt_text("qBittorrent 用户名", default="admin") + password = _prompt_text("qBittorrent 密码", secret=True) + category = _prompt_yes_no("是否启用 qBittorrent 分类", default=False) + return { + "name": config_name, + "type": "qbittorrent", + "default": True, + "enabled": True, + "config": { + "host": host, + "username": username, + "password": password, + "category": category, + }, + "path_mapping": _collect_path_mapping(), + } + + host = _prompt_text("Transmission RPC 地址", default="http://127.0.0.1:9091") + username = _prompt_text("Transmission 用户名", allow_empty=True, default="") + password = _prompt_text("Transmission 密码", allow_empty=True, secret=True) + return { + "name": config_name, + "type": "transmission", + "default": True, + "enabled": True, + "config": { + "host": host, + "username": username, + "password": password, + }, + "path_mapping": _collect_path_mapping(), + } + + +def _collect_media_server_config() -> Optional[dict[str, Any]]: + print_step("媒体服务器配置") + server_type = _prompt_choice( + "选择媒体服务器类型", + { + "skip": "跳过", + "emby": "Emby", + "jellyfin": "Jellyfin", + "plex": "Plex", + }, + default="skip", + ) + if server_type == "skip": + return None + + config_name = _prompt_text("媒体服务器名称", default=server_type) + default_host = { + "emby": "http://127.0.0.1:8096", + "jellyfin": "http://127.0.0.1:8096", + "plex": "http://127.0.0.1:32400", + }[server_type] + host = _prompt_text("媒体服务器地址", default=default_host) + play_host = _prompt_text("外部访问地址(可选)", default="", allow_empty=True) + + if server_type == "plex": + config = { + "host": host, + "token": _prompt_text("Plex Token", secret=True), + } + else: + config = { + "host": host, + "apikey": _prompt_text("媒体服务器 API Key", secret=True), + } + if server_type == "emby": + username = _prompt_text("Emby 管理员用户名(可选)", default="", allow_empty=True) + if username: + config["username"] = username + + if play_host: + config["play_host"] = play_host + + return { + "name": config_name, + "type": server_type, + "enabled": True, + "config": config, + "sync_libraries": [], + } + + +def _collect_notification_config() -> Optional[dict[str, Any]]: + print_step("消息通知配置") + notification_type = _prompt_choice( + "选择通知渠道类型", + { + "skip": "跳过", + "telegram": "Telegram", + "wechat": "企业微信机器人", + "slack": "Slack", + }, + default="skip", + ) + if notification_type == "skip": + return None + + config_name = _prompt_text("通知渠道名称", default=notification_type) + if notification_type == "telegram": + config = { + "TELEGRAM_TOKEN": _prompt_text("Telegram Bot Token", secret=True), + "TELEGRAM_CHAT_ID": _prompt_text("Telegram Chat ID"), + } + api_url = _prompt_text("自定义 Telegram API 地址(可选)", default="", allow_empty=True) + if api_url: + config["API_URL"] = api_url + elif notification_type == "wechat": + config = { + "WECHAT_MODE": "bot", + "WECHAT_BOT_ID": _prompt_text("企业微信机器人 ID"), + "WECHAT_BOT_SECRET": _prompt_text("企业微信机器人 Secret", secret=True), + } + chat_id = _prompt_text("默认发送对象(可选)", default="", allow_empty=True) + admins = _prompt_text("管理员用户列表,多个逗号分隔(可选)", default="", allow_empty=True) + if chat_id: + config["WECHAT_BOT_CHAT_ID"] = chat_id + if admins: + config["WECHAT_ADMINS"] = admins + else: + config = { + "SLACK_OAUTH_TOKEN": _prompt_text("Slack OAuth Token", secret=True), + "SLACK_APP_TOKEN": _prompt_text("Slack App Token", secret=True), + } + channel = _prompt_text("Slack 默认频道(可选)", default="", allow_empty=True) + if channel: + config["SLACK_CHANNEL"] = channel + + return { + "name": config_name, + "type": notification_type, + "enabled": True, + "config": config, + "switchs": list(NOTIFICATION_SWITCH_TYPES), + } + + +def run_setup_wizard(force_token: bool) -> dict[str, Any]: + if not _is_interactive(): + raise RuntimeError("交互式向导需要在终端中运行,请直接执行 moviepilot setup --wizard 或 moviepilot init --wizard") + + print_step("启动本地初始化向导,直接回车可接受默认值,部分步骤可选择跳过") + + existing_token = read_env_value("API_TOKEN") or "" + if existing_token and len(existing_token) >= 16 and not force_token: + if _prompt_yes_no("检测到现有 API_TOKEN,是否继续使用", default=True): + api_token = ensure_api_token(force_token=False) + else: + if _prompt_yes_no("是否自动生成新的 API_TOKEN", default=True): + api_token = ensure_api_token(force_token=True) + else: + while True: + custom_token = _prompt_text("请输入新的 API_TOKEN(至少 16 位)", secret=True) + if len(custom_token) >= 16: + api_token = ensure_api_token(force_token=True, token=custom_token) + break + print("API_TOKEN 长度不能少于 16 个字符。") + else: + if _prompt_yes_no("是否自动生成 API_TOKEN", default=True): + api_token = ensure_api_token(force_token=force_token or bool(existing_token)) + else: + while True: + custom_token = _prompt_text("请输入 API_TOKEN(至少 16 位)", secret=True) + if len(custom_token) >= 16: + api_token = ensure_api_token(force_token=True, token=custom_token) + break + print("API_TOKEN 长度不能少于 16 个字符。") + + return { + "api_token": api_token, + "directories": [_collect_directory_config()], + "downloader": _collect_downloader_config(), + "mediaserver": _collect_media_server_config(), + "notification": _collect_notification_config(), + } + + +def _merge_named_item(existing_items: list[dict], new_item: dict) -> list[dict]: + merged = list(existing_items or []) + new_name = new_item.get("name") + for index, item in enumerate(merged): + if item.get("name") == new_name: + merged[index] = new_item + return merged + merged.append(new_item) + return merged + + +def _merge_directory_item(existing_items: list[dict], new_item: dict) -> list[dict]: + merged = list(existing_items or []) + for index, item in enumerate(merged): + if item.get("name") == new_item.get("name") or ( + item.get("download_path") == new_item.get("download_path") + and item.get("library_path") == new_item.get("library_path") + ): + new_copy = dict(new_item) + new_copy["priority"] = item.get("priority", new_item.get("priority", 0)) + merged[index] = new_copy + return merged + + new_copy = dict(new_item) + max_priority = max((int(item.get("priority", 0) or 0) for item in merged), default=-1) + new_copy["priority"] = max_priority + 1 if merged else int(new_item.get("priority", 0) or 0) + merged.append(new_copy) + return merged + + +def _merge_notification_switches(existing_items: list[dict]) -> list[dict]: + merged: dict[str, dict] = {} + for item in existing_items or []: + switch_type = str(item.get("type") or "").strip() + if switch_type: + merged[switch_type] = dict(item) + + for switch_type in NOTIFICATION_SWITCH_TYPES: + merged.setdefault( + switch_type, + { + "type": switch_type, + "action": "all", + }, + ) + + preferred_order = [switch for switch in NOTIFICATION_SWITCH_TYPES if switch in merged] + extras = [key for key in merged if key not in preferred_order] + return [merged[key] for key in [*preferred_order, *extras]] + + +def apply_local_system_config(config_payload: dict[str, Any]) -> None: + for directory in config_payload.get("directories") or []: + download_path = directory.get("download_path") + library_path = directory.get("library_path") + if download_path: + Path(download_path).mkdir(parents=True, exist_ok=True) + if library_path: + Path(library_path).mkdir(parents=True, exist_ok=True) + + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + try: + from app.db.init import init_db, update_db + from app.db.systemconfig_oper import SystemConfigOper + from app.schemas.types import SystemConfigKey + except ModuleNotFoundError as exc: + raise RuntimeError("当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup") from exc + + init_db() + update_db() + + system_config = SystemConfigOper() + directory_items = config_payload.get("directories") or [] + if directory_items: + current_directories = system_config.get(SystemConfigKey.Directories) or [] + for item in directory_items: + current_directories = _merge_directory_item(current_directories, item) + system_config.set(SystemConfigKey.Directories, current_directories) + + downloader_item = config_payload.get("downloader") + if downloader_item: + current_downloaders = system_config.get(SystemConfigKey.Downloaders) or [] + current_downloaders = _merge_named_item(current_downloaders, downloader_item) + system_config.set(SystemConfigKey.Downloaders, current_downloaders) + + mediaserver_item = config_payload.get("mediaserver") + if mediaserver_item: + current_servers = system_config.get(SystemConfigKey.MediaServers) or [] + current_servers = _merge_named_item(current_servers, mediaserver_item) + system_config.set(SystemConfigKey.MediaServers, current_servers) + + notification_item = config_payload.get("notification") + if notification_item: + current_notifications = system_config.get(SystemConfigKey.Notifications) or [] + current_notifications = _merge_named_item(current_notifications, notification_item) + system_config.set(SystemConfigKey.Notifications, current_notifications) + current_switches = system_config.get(SystemConfigKey.NotificationSwitchs) or [] + system_config.set(SystemConfigKey.NotificationSwitchs, _merge_notification_switches(current_switches)) + + system_config.set(SystemConfigKey.SetupWizardState, True) + print_step("已写入本地系统配置") + + +def init_local( + *, + resources_repo: Optional[Path], + resource_dir: Optional[Path], + skip_resources: bool, + resources_ready: bool, + force_token: bool, + wizard: bool, +) -> None: + ensure_local_dirs() + + wizard_payload: Optional[dict[str, Any]] = None + if wizard: + wizard_payload = run_setup_wizard(force_token=force_token) + else: + ensure_api_token(force_token=force_token) + + if skip_resources: + if resources_ready: + print_step("资源文件已完成同步") + else: + print_step("已跳过资源初始化") + else: + install_resources(resources_repo=resources_repo, resource_dir=resource_dir) + + if wizard_payload: + apply_local_system_config(wizard_payload) + + +def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path: + ensure_supported_python(python_bin) + venv_dir = venv_dir.expanduser().resolve() + venv_python = get_venv_python(venv_dir) + + if recreate and venv_dir.exists(): + print_step(f"删除已有虚拟环境:{venv_dir}") + shutil.rmtree(venv_dir) + + if not venv_python.exists(): + print_step(f"创建虚拟环境:{venv_dir}") + run([python_bin, "-m", "venv", str(venv_dir)]) + else: + print_step(f"复用已有虚拟环境:{venv_dir}") + + print_step("升级 pip") + run([str(venv_python), "-m", "pip", "install", "--upgrade", "pip"]) + + print_step("安装项目依赖") + run([str(venv_python), "-m", "pip", "install", "-r", str(ROOT / "requirements.txt")]) + return venv_python + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="MoviePilot 本地安装与初始化工具") + subparsers = parser.add_subparsers(dest="command", required=True) + + install_parser = subparsers.add_parser("install-deps", help="创建虚拟环境并安装后端依赖") + install_parser.add_argument("--python", default=sys.executable, help="用于创建虚拟环境的 Python 解释器") + install_parser.add_argument("--venv", default=str(ROOT / "venv"), help="虚拟环境目录") + install_parser.add_argument("--recreate", action="store_true", help="删除并重建虚拟环境") + + frontend_parser = subparsers.add_parser("install-frontend", help="下载前端 release 并安装本地运行时") + frontend_parser.add_argument("--version", default="latest", help="前端版本,默认 latest") + frontend_parser.add_argument("--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本") + + resources_parser = subparsers.add_parser("install-resources", help="下载资源文件并同步到 app/helper") + resources_parser.add_argument("--resources-repo", help="本地 MoviePilot-Resources 仓库路径") + resources_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + + init_parser = subparsers.add_parser("init", help="初始化本地配置与资源文件") + init_parser.add_argument("--resources-repo", help="本地 MoviePilot-Resources 仓库路径") + init_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + init_parser.add_argument("--skip-resources", action="store_true", help="只初始化配置,不同步资源文件") + init_parser.add_argument("--force-token", action="store_true", help="强制重置 API_TOKEN") + init_parser.add_argument("--wizard", action="store_true", help="启动交互式初始化向导") + + setup_parser = subparsers.add_parser("setup", help="执行 install-deps、install-frontend、install-resources 和 init") + setup_parser.add_argument("--python", default=sys.executable, help="用于创建虚拟环境的 Python 解释器") + setup_parser.add_argument("--venv", default=str(ROOT / "venv"), help="虚拟环境目录") + setup_parser.add_argument("--recreate", action="store_true", help="删除并重建虚拟环境") + setup_parser.add_argument("--frontend-version", default="latest", help="前端版本,默认 latest") + setup_parser.add_argument("--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本") + setup_parser.add_argument("--resources-repo", help="本地 MoviePilot-Resources 仓库路径") + setup_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + setup_parser.add_argument("--skip-resources", action="store_true", help="只初始化配置,不同步资源文件") + setup_parser.add_argument("--force-token", action="store_true", help="强制重置 API_TOKEN") + setup_parser.add_argument("--wizard", action="store_true", help="安装完成后启动交互式初始化向导") + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + if args.command == "install-deps": + venv_python = install_deps( + python_bin=args.python, + venv_dir=Path(args.venv), + recreate=args.recreate, + ) + print_step(f"后端依赖安装完成,可执行:{venv_python} -m app.cli") + return 0 + + if args.command == "install-frontend": + result = install_frontend(frontend_version=args.version, node_version=args.node_version) + print_step(f"前端安装完成,版本:{result['version']}") + return 0 + + if args.command == "install-resources": + install_resources( + resources_repo=Path(args.resources_repo) if args.resources_repo else None, + resource_dir=Path(args.resource_dir) if args.resource_dir else None, + ) + return 0 + + if args.command == "init": + init_local( + resources_repo=Path(args.resources_repo) if args.resources_repo else None, + resource_dir=Path(args.resource_dir) if args.resource_dir else None, + skip_resources=args.skip_resources, + resources_ready=False, + force_token=args.force_token, + wizard=args.wizard, + ) + print_step("初始化完成") + return 0 + + if args.command == "setup": + venv_python = install_deps( + python_bin=args.python, + venv_dir=Path(args.venv), + recreate=args.recreate, + ) + install_frontend(frontend_version=args.frontend_version, node_version=args.node_version) + resources_installed = False + if not args.skip_resources: + install_resources( + resources_repo=Path(args.resources_repo) if args.resources_repo else None, + resource_dir=Path(args.resource_dir) if args.resource_dir else None, + ) + resources_installed = True + init_local( + resources_repo=Path(args.resources_repo) if args.resources_repo else None, + resource_dir=Path(args.resource_dir) if args.resource_dir else None, + skip_resources=args.skip_resources or resources_installed, + resources_ready=resources_installed, + force_token=args.force_token, + wizard=args.wizard, + ) + print_step(f"本地环境已完成安装与初始化:{venv_python}") + return 0 + except subprocess.CalledProcessError as exc: + print(f"命令执行失败,退出码:{exc.returncode}", file=sys.stderr) + return exc.returncode + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main())