From 02a98f832f09ae5d3225cc4ebfc7a3d69d6c5354 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 16 Apr 2026 14:55:31 +0800 Subject: [PATCH] fix local cli install and config workflow --- .gitignore | 1 + docs/cli.md | 153 +++++++++++++------ moviepilot | 112 +++++++++++++- scripts/bootstrap-local.sh | 131 ++++++++++++++-- scripts/local_setup.py | 303 ++++++++++++++++++++++++++++++++++++- 5 files changed, 635 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 34e62a92..9c103205 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ config/temp/ config/cache/ .runtime/ public/ +.moviepilot.env *.pyc *.log .vscode diff --git a/docs/cli.md b/docs/cli.md index d4db4fdb..903ad31e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,11 +1,9 @@ # MoviePilot CLI -`moviepilot` 是 MoviePilot 本地源码模式的一体化入口,用于安装后端、安装前端 release、同步资源文件、初始化配置,以及统一管理前后端服务。 +`moviepilot` 是 MoviePilot 本地源码模式的一体化入口,负责本地安装、初始化、更新,以及前后端服务管理。 ## 一键安装 -直接从仓库读取脚本并执行: - ```shell curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash ``` @@ -20,19 +18,44 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst - 下载 `MoviePilot-Resources` 主分支资源 - 将 `resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) - 下载本地 Node 运行时并安装前端运行依赖 +- 执行初始化向导 - 创建全局 `moviepilot` 命令 - 默认启动前后端服务 +如果安装完成后当前终端仍提示找不到 `moviepilot`: + +- 重新打开终端 +- 如果脚本提示使用了 `~/.local/bin`,执行 `source ~/.zshrc` 或 `source ~/.bashrc` + +## 配置目录 + +本地 CLI 默认将配置目录放在程序目录外,避免直接删除程序目录时把配置一并删掉。 + +- macOS:`~/Library/Application Support/MoviePilot` +- Linux:`${XDG_CONFIG_HOME:-~/.config}/moviepilot` + +可以在安装或初始化时手动指定: + +```shell +moviepilot setup --config-dir /path/to/moviepilot-config +moviepilot init --config-dir /path/to/moviepilot-config +``` + +查看当前实际配置目录: + +```shell +moviepilot config path +``` + ## 目录说明 -本地安装完成后,主要运行目录如下: - - 后端代码:仓库根目录 +- 外置配置目录:`moviepilot config path` 输出的 `Config Dir` - 前端静态文件:`public/` - 前端本地 Node 运行时:`.runtime/node/` -- 后端日志:`config/logs/moviepilot.log` -- 后端启动日志:`config/logs/moviepilot.stdout.log` -- 前端启动日志:`config/logs/moviepilot.frontend.stdout.log` +- 后端日志:`/logs/moviepilot.log` +- 后端启动日志:`/logs/moviepilot.stdout.log` +- 前端启动日志:`/logs/moviepilot.frontend.stdout.log` ## 帮助与发现 @@ -50,6 +73,8 @@ moviepilot commands moviepilot help install moviepilot help init moviepilot help setup +moviepilot help update +moviepilot help agent moviepilot help config moviepilot help config set moviepilot help tool @@ -79,6 +104,10 @@ moviepilot install frontend moviepilot install resources moviepilot init moviepilot setup +moviepilot update backend +moviepilot update frontend +moviepilot update all +moviepilot agent moviepilot start moviepilot stop moviepilot restart @@ -109,6 +138,7 @@ moviepilot install deps moviepilot install deps --python python3.12 moviepilot install deps --venv /path/to/venv moviepilot install deps --recreate +moviepilot install deps --config-dir /path/to/moviepilot-config ``` 安装前端 release: @@ -118,6 +148,7 @@ moviepilot install frontend moviepilot install frontend --version latest moviepilot install frontend --version v2.9.31 moviepilot install frontend --node-version 20.12.1 +moviepilot install frontend --config-dir /path/to/moviepilot-config ``` 说明: @@ -132,6 +163,7 @@ moviepilot install frontend --node-version 20.12.1 moviepilot install resources moviepilot install resources --resources-repo /path/to/MoviePilot-Resources moviepilot install resources --resource-dir /path/to/resources.v2 +moviepilot install resources --config-dir /path/to/moviepilot-config ``` 说明: @@ -149,6 +181,7 @@ moviepilot init moviepilot init --wizard moviepilot init --skip-resources moviepilot init --force-token +moviepilot init --config-dir /path/to/moviepilot-config ``` 一体化安装: @@ -160,6 +193,7 @@ moviepilot setup --frontend-version latest moviepilot setup --node-version 20.12.1 moviepilot setup --skip-resources moviepilot setup --recreate +moviepilot setup --config-dir /path/to/moviepilot-config ``` `moviepilot setup` 会串行执行: @@ -177,9 +211,60 @@ moviepilot setup --recreate - 媒体服务器 - 消息通知渠道 +## 更新命令 + +更新后端: + +```shell +moviepilot update backend +moviepilot update backend --ref latest +moviepilot update backend --ref v2 +moviepilot update backend --ref v2.9.31 +``` + +更新前端: + +```shell +moviepilot update frontend +moviepilot update frontend --frontend-version latest +moviepilot update frontend --frontend-version v2.9.31 +``` + +整体更新: + +```shell +moviepilot update all +moviepilot update all --ref latest --frontend-version latest +moviepilot update all --skip-resources +``` + +说明: + +- `update backend` 会更新 Git 仓库并重新安装后端依赖 +- `update frontend` 会下载并替换前端 release +- `update all` 会同时更新后端、前端,默认也会同步资源文件 +- 更新前请先执行 `moviepilot stop` + +## Agent 命令 + +直接给智能体发送一次请求: + +```shell +moviepilot agent 帮我分析最近一次搜索失败的原因 +moviepilot agent --user-id admin 帮我检查当前下载器配置 +moviepilot agent --session cli-debug-1 帮我看看为什么没有自动整理 +moviepilot agent --new-session 帮我总结当前系统配置有什么明显问题 +``` + +说明: + +- `moviepilot agent` 直接在本地环境里发起一次智能体请求 +- 默认每次可自动创建新会话,也可以通过 `--session` 指定会话 ID +- 使用前需要先正确配置 LLM 相关参数,并打开 `AI_AGENT_ENABLE` + ## 服务管理命令 -`moviepilot start/stop/restart/status` 现在统一管理前后端。 +`moviepilot start/stop/restart/status` 统一管理前后端。 启动、停止、重启与状态: @@ -258,37 +343,39 @@ moviepilot config describe API_TOKEN --show-secrets - `config list` 显示当前配置值 - `config keys` 显示配置项名称、类型和默认值 -- `config describe` 显示单个配置项的类型、默认值、当前值与配置文件位置 -- 如果前后端正在运行,更新配置后需要 `moviepilot restart` +- `config describe` 显示单个配置项的类型、默认值和当前值 -## 工具命令 +## Tool 命令 -工具命令依赖后端已启动,并且本地配置中存在有效的 `API_TOKEN`。 - -列出工具: +列出所有 MCP 工具: ```shell moviepilot tool list ``` -查看工具参数: +查看单个工具的参数说明: ```shell -moviepilot tool show search_media +moviepilot tool show query_schedulers +moviepilot tool show search_torrents ``` -调用工具: +运行工具: ```shell -moviepilot tool run search_media title="Inception" media_type=movie moviepilot tool run query_schedulers +moviepilot tool run search_torrents media_type=movie tmdb_id=12345 ``` -`tool list` 和 `tool show` 是查看“当前后端实际暴露的全部工具与参数”的推荐方式。 +说明: -## 调度命令 +- `tool list` 用于动态发现当前服务可调用的工具 +- `tool show` 会输出参数名、类型和描述 +- `tool run` 参数格式固定为 `key=value` -查看调度任务: +## Scheduler 命令 + +列出调度任务: ```shell moviepilot scheduler list @@ -297,25 +384,5 @@ 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 +moviepilot scheduler run subscribe_refresh ``` diff --git a/moviepilot b/moviepilot index 995291f2..df577558 100755 --- a/moviepilot +++ b/moviepilot @@ -9,11 +9,13 @@ Usage: moviepilot [BOOTSTRAP COMMAND] | [RUNTIME 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] + moviepilot install deps [--python PYTHON] [--venv PATH] [--recreate] [--config-dir PATH] + moviepilot install frontend [--version latest] [--node-version 20.12.1] [--config-dir PATH] + moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH] + moviepilot init [--skip-resources] [--force-token] [--wizard] [--config-dir PATH] + moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--config-dir PATH] + moviepilot update {backend|frontend|all} [OPTIONS] + moviepilot agent [OPTIONS] MESSAGE... Runtime Commands: moviepilot start|stop|restart|status|logs|version @@ -25,6 +27,7 @@ Discovery Commands: moviepilot help moviepilot help config moviepilot help install + moviepilot help update moviepilot commands Examples: @@ -32,6 +35,8 @@ Examples: moviepilot install frontend moviepilot install resources moviepilot setup --wizard + moviepilot update all + moviepilot agent 帮我分析最近一次搜索失败 moviepilot help config moviepilot config keys moviepilot start @@ -47,6 +52,10 @@ Bootstrap Commands install resources init setup + update backend + update frontend + update all + agent Runtime Commands start @@ -85,14 +94,17 @@ Options: --python PYTHON 用于创建虚拟环境的 Python 解释器 --venv PATH 虚拟环境目录,默认 ./venv --recreate 删除并重建虚拟环境 + --config-dir PATH 指定配置目录 frontend: --version TAG 前端版本,默认 latest --node-version VER 本地 Node 运行时版本,默认 20.12.1 + --config-dir PATH 指定配置目录 resources: --resources-repo PATH 本地 MoviePilot-Resources 仓库路径 --resource-dir PATH 直接指定 resources.v2 目录 + --config-dir PATH 指定配置目录 -h, --help 显示帮助 EOF @@ -106,6 +118,7 @@ Options: --skip-resources 跳过资源同步 --force-token 强制重置 API_TOKEN --wizard 启动交互式初始化向导 + --config-dir PATH 指定配置目录 -h, --help 显示帮助 EOF } @@ -123,6 +136,41 @@ Options: --skip-resources 跳过资源同步 --force-token 强制重置 API_TOKEN --wizard 安装完成后启动交互式初始化向导 + --config-dir PATH 指定配置目录 + -h, --help 显示帮助 +EOF +} + +show_update_help() { + cat <<'EOF' +Usage: + moviepilot update backend [OPTIONS] + moviepilot update frontend [OPTIONS] + moviepilot update all [OPTIONS] + +Options: + --ref REF 后端 Git 版本,默认 latest + --frontend-version TAG 前端版本,默认 latest + --node-version VER 本地 Node 运行时版本,默认 20.12.1 + --python PYTHON 用于安装后端依赖的 Python 解释器 + --venv PATH 虚拟环境目录,默认 ./venv + --recreate 删除并重建虚拟环境 + --skip-resources 更新 all 时跳过资源同步 + --config-dir PATH 指定配置目录 + -h, --help 显示帮助 +EOF +} + +show_agent_help() { + cat <<'EOF' +Usage: + moviepilot agent [OPTIONS] MESSAGE... + +Options: + --session ID 指定会话 ID + --new-session 强制创建新会话 + --user-id ID 智能体上下文中的用户 ID,默认 cli + --config-dir PATH 指定配置目录 -h, --help 显示帮助 EOF } @@ -139,6 +187,22 @@ find_system_python() { return 1 } +default_config_dir() { + case "$(uname -s)" in + Darwin) + printf '%s\n' "$HOME/Library/Application Support/MoviePilot" + ;; + *) + printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/moviepilot" + ;; + esac +} + +legacy_config_exists() { + local legacy_dir="$ROOT/config" + [[ -f "$legacy_dir/app.env" ]] || [[ -f "$legacy_dir/user.db" ]] || [[ -d "$legacy_dir/logs" ]] || [[ -d "$legacy_dir/temp" ]] || [[ -d "$legacy_dir/cache" ]] || [[ -d "$legacy_dir/cookies" ]] || [[ -d "$legacy_dir/sites" ]] +} + run_runtime_cli() { if [ ! -x "$VENV_PYTHON" ]; then echo "未找到项目虚拟环境,请先执行 moviepilot install deps 或 moviepilot setup" >&2 @@ -182,6 +246,14 @@ show_command_help() { show_setup_help exit 0 ;; + agent) + show_agent_help + exit 0 + ;; + update) + show_update_help + exit 0 + ;; commands) show_commands exit 0 @@ -207,6 +279,20 @@ ROOT="$(cd -P "$(dirname "$SOURCE")" && pwd)" VENV_PYTHON="$ROOT/venv/bin/python" SETUP_SCRIPT="$ROOT/scripts/local_setup.py" +if [ -z "${CONFIG_DIR:-}" ] && [ -f "$ROOT/.moviepilot.env" ]; then + # shellcheck disable=SC1090 + . "$ROOT/.moviepilot.env" +fi + +if [ -z "${CONFIG_DIR:-}" ]; then + if legacy_config_exists; then + CONFIG_DIR="$ROOT/config" + else + CONFIG_DIR="$(default_config_dir)" + fi +fi +export CONFIG_DIR + BOOTSTRAP_PYTHON="" if [ -x "$VENV_PYTHON" ]; then BOOTSTRAP_PYTHON="$VENV_PYTHON" @@ -270,6 +356,22 @@ case "${1:-}" in fi exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@" ;; + update) + shift + if [ -z "$BOOTSTRAP_PYTHON" ]; then + echo "未找到可用的 Python 解释器,请先安装 Python 3" >&2 + exit 1 + fi + exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@" + ;; + agent) + shift + if [ -z "$BOOTSTRAP_PYTHON" ]; then + echo "未找到可用的 Python 解释器,请先安装 Python 3" >&2 + exit 1 + fi + exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" agent "$@" + ;; esac if [ ! -x "$VENV_PYTHON" ]; then diff --git a/scripts/bootstrap-local.sh b/scripts/bootstrap-local.sh index 3bed0f75..1fe346ee 100755 --- a/scripts/bootstrap-local.sh +++ b/scripts/bootstrap-local.sh @@ -7,11 +7,17 @@ WORKDIR="$PWD" APP_DIR_NAME="MoviePilot" LINK_CLI="true" LINK_PATH="" +CONFIG_DIR="" RUN_WIZARD="true" START_AFTER_INSTALL="true" NON_INTERACTIVE="false" OS_NAME="Unknown" PYTHON_BIN="" +PROMPT_INPUT="/dev/stdin" +PROMPT_OUTPUT="/dev/stdout" +HAS_TTY="false" +PATH_RC_FILE="" +PATH_UPDATED="false" usage() { cat <"$PROMPT_OUTPUT" else - read -r -p "$label: " answer || true + printf '%s: ' "$label" >"$PROMPT_OUTPUT" fi + IFS= read -r answer <"$PROMPT_INPUT" || true + if [[ -z "$answer" ]]; then + answer="$default_value" + fi printf '%s\n' "$answer" } @@ -159,7 +196,8 @@ prompt_yes_no() { fi while true; do - read -r -p "$label $prompt: " answer || true + printf '%s %s: ' "$label" "$prompt" >"$PROMPT_OUTPUT" + IFS= read -r answer <"$PROMPT_INPUT" || true answer="${answer,,}" if [[ -z "$answer" ]]; then answer="$default_value" @@ -168,16 +206,17 @@ prompt_yes_no() { y|yes) return 0 ;; n|no) return 1 ;; esac - echo "请输入 y 或 n。" + printf '请输入 y 或 n。\n' >"$PROMPT_OUTPUT" done } run_interactive_guide() { - echo "==> 当前系统: $OS_NAME" - echo "==> 将自动拉取 MoviePilot,并下载前端 release、资源文件与本地 Node 运行时" + printf '==> 当前系统: %s\n' "$OS_NAME" >"$PROMPT_OUTPUT" + printf '==> 将自动拉取 MoviePilot,并下载前端 release、资源文件与本地 Node 运行时\n' >"$PROMPT_OUTPUT" WORKDIR="$(prompt_text "安装目录" "$WORKDIR")" APP_DIR_NAME="$(prompt_text "主项目目录名" "$APP_DIR_NAME")" + CONFIG_DIR="$(prompt_text "配置目录" "$CONFIG_DIR")" if prompt_yes_no "安装过程中进入 MoviePilot 初始化向导" "y"; then RUN_WIZARD="true" @@ -211,6 +250,49 @@ ensure_link_path() { mkdir -p "$(dirname "$LINK_PATH")" } +detect_rc_file() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + zsh) + printf '%s\n' "$HOME/.zshrc" + ;; + bash) + printf '%s\n' "$HOME/.bashrc" + ;; + *) + printf '%s\n' "$HOME/.profile" + ;; + esac +} + +ensure_path_configured() { + if [[ "$LINK_CLI" != "true" ]]; then + return + fi + + local bin_dir + bin_dir="$(dirname "$LINK_PATH")" + export PATH="$bin_dir:$PATH" + + if [[ "$bin_dir" != "$HOME/.local/bin" ]]; then + return + fi + + PATH_RC_FILE="$(detect_rc_file)" + local export_line='export PATH="$HOME/.local/bin:$PATH"' + mkdir -p "$(dirname "$PATH_RC_FILE")" + touch "$PATH_RC_FILE" + + if ! grep -Fqs "$export_line" "$PATH_RC_FILE"; then + { + printf '\n# MoviePilot CLI\n' + printf '%s\n' "$export_line" + } >>"$PATH_RC_FILE" + PATH_UPDATED="true" + fi +} + while [[ $# -gt 0 ]]; do case "$1" in --workdir) @@ -225,6 +307,10 @@ while [[ $# -gt 0 ]]; do REPO_URL="$2" shift 2 ;; + --config-dir) + CONFIG_DIR="$2" + shift 2 + ;; --link-path) LINK_PATH="$2" shift 2 @@ -258,12 +344,16 @@ while [[ $# -gt 0 ]]; do done detect_os +setup_prompt_io require_prereqs ensure_link_path -if [[ "$NON_INTERACTIVE" != "true" && -t 0 && -t 1 ]]; then +if [[ "$NON_INTERACTIVE" != "true" && "$HAS_TTY" == "true" ]]; then run_interactive_guide ensure_link_path +elif [[ "$RUN_WIZARD" == "true" && "$HAS_TTY" != "true" ]]; then + echo "==> 未检测到可用终端输入,已跳过初始化向导。安装完成后可手动执行:moviepilot setup --wizard" + RUN_WIZARD="false" fi mkdir -p "$WORKDIR" @@ -279,15 +369,20 @@ fi cd "$APP_DIR" echo "==> 执行本地环境安装与初始化" -SETUP_ARGS=(setup) +SETUP_ARGS=(setup --config-dir "$CONFIG_DIR") if [[ "$RUN_WIZARD" == "true" ]]; then SETUP_ARGS+=(--wizard) fi -./moviepilot "${SETUP_ARGS[@]}" +if [[ "$HAS_TTY" == "true" ]]; then + ./moviepilot "${SETUP_ARGS[@]}" <"$PROMPT_INPUT" +else + ./moviepilot "${SETUP_ARGS[@]}" +fi if [[ "$LINK_CLI" == "true" ]]; then echo "==> 创建全局 moviepilot 命令到 $LINK_PATH" ln -sf "$APP_DIR/moviepilot" "$LINK_PATH" + ensure_path_configured fi if [[ "$START_AFTER_INSTALL" == "true" ]]; then @@ -300,6 +395,7 @@ cat < Path: + if platform.system() == "Darwin": + return Path.home() / "Library" / "Application Support" / "MoviePilot" + return Path(os.getenv("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "moviepilot" + + +def _legacy_runtime_config_exists() -> bool: + markers = [ + LEGACY_CONFIG_DIR / "app.env", + LEGACY_CONFIG_DIR / "user.db", + LEGACY_CONFIG_DIR / "logs", + LEGACY_CONFIG_DIR / "temp", + LEGACY_CONFIG_DIR / "cache", + LEGACY_CONFIG_DIR / "cookies", + LEGACY_CONFIG_DIR / "sites", + ] + return any(marker.exists() for marker in markers) + + +def _read_install_env_config_dir() -> Optional[Path]: + if not INSTALL_ENV_FILE.exists(): + return None + + for line in INSTALL_ENV_FILE.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + if key.strip() != "CONFIG_DIR": + continue + return Path(value.strip().strip('"').strip("'")).expanduser() + return None + + +def _set_config_dir(config_dir: Path) -> Path: + global CONFIG_DIR, LOG_DIR, CACHE_DIR, TEMP_DIR, COOKIE_DIR, ENV_FILE + + CONFIG_DIR = config_dir.expanduser().resolve() + LOG_DIR = CONFIG_DIR / "logs" + CACHE_DIR = CONFIG_DIR / "cache" + TEMP_DIR = CONFIG_DIR / "temp" + COOKIE_DIR = CONFIG_DIR / "cookies" + ENV_FILE = CONFIG_DIR / "app.env" + os.environ["CONFIG_DIR"] = str(CONFIG_DIR) + return CONFIG_DIR + + +def _write_install_env(config_dir: Path) -> None: + INSTALL_ENV_FILE.write_text( + f"CONFIG_DIR={shlex.quote(str(config_dir.expanduser().resolve()))}\n", + encoding="utf-8", + ) + + +def _seed_default_config_files(target_dir: Path) -> None: + for name in ("category.yaml",): + source = LEGACY_CONFIG_DIR / name + target = target_dir / name + if source.exists() and not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def _migrate_legacy_config_if_needed(target_dir: Path) -> None: + target_dir = target_dir.expanduser().resolve() + if target_dir == LEGACY_CONFIG_DIR.resolve(): + return + if not _legacy_runtime_config_exists(): + _seed_default_config_files(target_dir) + return + + target_dir.mkdir(parents=True, exist_ok=True) + for source in sorted(LEGACY_CONFIG_DIR.iterdir()): + target = target_dir / source.name + if target.exists(): + continue + if source.is_dir(): + shutil.copytree(source, target) + else: + shutil.copy2(source, target) + print_step(f"已将现有本地配置迁移到 {target_dir}") + + +def configure_config_dir(explicit: Optional[Path] = None, *, persist: bool = False, prefer_external: bool = False) -> Path: + if explicit: + config_dir = explicit.expanduser().resolve() + elif os.getenv("CONFIG_DIR"): + config_dir = Path(os.environ["CONFIG_DIR"]).expanduser().resolve() + else: + install_env_dir = _read_install_env_config_dir() + if install_env_dir: + config_dir = install_env_dir.resolve() + elif prefer_external: + config_dir = _default_config_dir().resolve() + elif _legacy_runtime_config_exists(): + config_dir = LEGACY_CONFIG_DIR.resolve() + else: + config_dir = _default_config_dir().resolve() + + _set_config_dir(config_dir) + if prefer_external: + _migrate_legacy_config_if_needed(config_dir) + if persist: + _write_install_env(config_dir) + return config_dir + + +configure_config_dir() + + def print_step(message: str) -> None: print(f"==> {message}") @@ -93,6 +209,7 @@ def ensure_supported_python(python_bin: str) -> None: 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) + _seed_default_config_files(CONFIG_DIR) def _load_env_lines() -> list[str]: @@ -888,6 +1005,120 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path: return venv_python +def _read_runtime_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 _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _services_running() -> list[str]: + running: list[str] = [] + runtime_files = { + "backend": TEMP_DIR / "moviepilot.runtime.json", + "frontend": TEMP_DIR / "moviepilot.frontend.runtime.json", + } + for name, runtime_file in runtime_files.items(): + payload = _read_runtime_file(runtime_file) + pid = payload.get("pid") if isinstance(payload, dict) else None + if pid and _pid_exists(int(pid)): + running.append(name) + return running + + +def ensure_services_stopped() -> None: + running = _services_running() + if running: + raise RuntimeError( + "检测到本地服务仍在运行(%s),请先执行 `moviepilot stop` 后再更新。" + % ", ".join(running) + ) + + +def _git_output(*args: str) -> str: + return capture(["git", *args], cwd=ROOT) + + +def _ensure_git_clean() -> None: + status = _git_output("status", "--porcelain") + if status.strip(): + raise RuntimeError("检测到当前仓库有未提交改动,请先提交或清理后再执行更新。") + + +def _update_backend_ref(ref: str) -> str: + if not (ROOT / ".git").exists(): + raise RuntimeError("当前目录不是 Git 仓库,无法更新后端代码。") + + _ensure_git_clean() + print_step("获取远端更新") + run(["git", "fetch", "--tags", "origin"], cwd=ROOT) + + current_branch = _git_output("rev-parse", "--abbrev-ref", "HEAD") + if ref == "latest": + if current_branch == "HEAD": + raise RuntimeError("当前仓库处于 detached HEAD 状态,请使用 `moviepilot update backend --ref ` 指定版本。") + print_step(f"更新后端代码到当前分支最新版本:{current_branch}") + run(["git", "pull", "--ff-only", "origin", current_branch], cwd=ROOT) + return current_branch + + print_step(f"切换后端代码到指定版本:{ref}") + run(["git", "checkout", ref], cwd=ROOT) + return ref + + +def update_backend(*, ref: str, python_bin: str, venv_dir: Path, recreate: bool) -> Path: + ensure_services_stopped() + resolved_ref = _update_backend_ref(ref=ref) + venv_python = install_deps(python_bin=python_bin, venv_dir=venv_dir, recreate=recreate) + print_step(f"后端更新完成:{resolved_ref}") + return venv_python + + +def run_agent_request(*, message: str, session_id: Optional[str], new_session: bool, user_id: str) -> dict[str, str]: + 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.agent import MoviePilotAgent + from app.core.config import settings + except ModuleNotFoundError as exc: + raise RuntimeError("当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup") from exc + + if not settings.AI_AGENT_ENABLE: + raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE") + + init_db() + update_db() + + session = (session_id or "").strip() + if new_session or not session: + session = f"cli-{uuid.uuid4().hex[:12]}" + + async def _run_agent() -> dict[str, str]: + agent = MoviePilotAgent(session_id=session, user_id=user_id or "cli") + agent.suppress_user_reply = True + await agent.process(message.strip()) + return { + "session_id": session, + "result": (agent._streamed_output or "").strip(), + } + + return asyncio.run(_run_agent()) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="MoviePilot 本地安装与初始化工具") subparsers = parser.add_subparsers(dest="command", required=True) @@ -896,14 +1127,17 @@ def build_parser() -> argparse.ArgumentParser: 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="删除并重建虚拟环境") + install_parser.add_argument("--config-dir", 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 运行时版本") + frontend_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录") 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 目录") + resources_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录") init_parser = subparsers.add_parser("init", help="初始化本地配置与资源文件") init_parser.add_argument("--resources-repo", help="本地 MoviePilot-Resources 仓库路径") @@ -911,6 +1145,7 @@ def build_parser() -> argparse.ArgumentParser: 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="启动交互式初始化向导") + init_parser.add_argument("--config-dir", 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 解释器") @@ -923,6 +1158,25 @@ def build_parser() -> argparse.ArgumentParser: 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="安装完成后启动交互式初始化向导") + setup_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录") + + agent_parser = subparsers.add_parser("agent", help="直接向 MoviePilot 智能体发送一次请求") + agent_parser.add_argument("message", nargs="+", help="发给智能体的文本请求") + agent_parser.add_argument("--session", help="会话 ID,默认自动生成") + agent_parser.add_argument("--new-session", action="store_true", help="忽略传入会话,强制创建新会话") + agent_parser.add_argument("--user-id", default="cli", help="智能体上下文中的用户 ID") + agent_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录") + + update_parser = subparsers.add_parser("update", help="更新本地后端、前端或全部组件") + update_parser.add_argument("target", choices=["backend", "frontend", "all"], help="更新目标") + update_parser.add_argument("--ref", default="latest", help="后端 Git 版本,默认 latest") + update_parser.add_argument("--frontend-version", default="latest", help="前端版本,默认 latest") + update_parser.add_argument("--node-version", default=DEFAULT_NODE_VERSION, help="本地 Node 运行时版本") + update_parser.add_argument("--python", default=sys.executable, help="用于安装后端依赖的 Python 解释器") + update_parser.add_argument("--venv", default=str(ROOT / "venv"), help="虚拟环境目录") + update_parser.add_argument("--recreate", action="store_true", help="删除并重建虚拟环境") + update_parser.add_argument("--skip-resources", action="store_true", help="更新 all 时跳过资源同步") + update_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录") return parser @@ -930,6 +1184,12 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: parser = build_parser() args = parser.parse_args() + explicit_config_dir = Path(args.config_dir) if getattr(args, "config_dir", None) else None + config_dir = configure_config_dir( + explicit=explicit_config_dir, + persist=True, + prefer_external=True, + ) try: if args.command == "install-deps": @@ -939,6 +1199,7 @@ def main() -> int: recreate=args.recreate, ) print_step(f"后端依赖安装完成,可执行:{venv_python} -m app.cli") + print_step(f"当前配置目录:{config_dir}") return 0 if args.command == "install-frontend": @@ -963,6 +1224,7 @@ def main() -> int: wizard=args.wizard, ) print_step("初始化完成") + print_step(f"当前配置目录:{config_dir}") return 0 if args.command == "setup": @@ -988,6 +1250,37 @@ def main() -> int: wizard=args.wizard, ) print_step(f"本地环境已完成安装与初始化:{venv_python}") + print_step(f"当前配置目录:{config_dir}") + return 0 + + if args.command == "agent": + result = run_agent_request( + message=" ".join(args.message), + session_id=args.session, + new_session=args.new_session, + user_id=args.user_id, + ) + if result.get("session_id"): + print_step(f"智能体会话:{result['session_id']}") + print(result.get("result") or "") + return 0 + + if args.command == "update": + ensure_services_stopped() + if args.target in {"backend", "all"}: + update_backend( + ref=args.ref, + python_bin=args.python, + venv_dir=Path(args.venv), + recreate=args.recreate, + ) + if args.target in {"frontend", "all"}: + frontend_result = install_frontend(frontend_version=args.frontend_version, node_version=args.node_version) + print_step(f"前端更新完成,版本:{frontend_result['version']}") + if args.target == "all" and not args.skip_resources: + install_resources(resources_repo=None, resource_dir=None) + print_step("资源文件已同步到最新") + print_step(f"更新完成,当前配置目录:{config_dir}") return 0 except subprocess.CalledProcessError as exc: print(f"命令执行失败,退出码:{exc.returncode}", file=sys.stderr)