fix local cli install and config workflow

This commit is contained in:
jxxghp
2026-04-16 14:55:31 +08:00
parent 9a2a241a30
commit 02a98f832f
5 changed files with 635 additions and 65 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ config/temp/
config/cache/
.runtime/
public/
.moviepilot.env
*.pyc
*.log
.vscode

View File

@@ -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`
- 后端日志:`<Config Dir>/logs/moviepilot.log`
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
- 前端启动日志:`<Config Dir>/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
```

View File

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

View File

@@ -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 <<EOF
@@ -21,6 +27,7 @@ Options:
--workdir PATH 克隆与安装的目标目录,默认当前目录
--app-dir NAME MoviePilot 目录名,默认 ${APP_DIR_NAME}
--repo-url URL 主项目仓库地址
--config-dir PATH 配置目录,默认使用程序目录外的系统配置目录
--link-path PATH 全局 moviepilot 软链接位置
--no-link-cli 安装完成后不创建全局 moviepilot 命令
--no-wizard 跳过 moviepilot setup 的交互式初始化向导
@@ -31,10 +38,35 @@ Options:
Examples:
$(basename "$0")
$(basename "$0") --workdir ~/Projects
$(basename "$0") --config-dir ~/.config/moviepilot-local
$(basename "$0") --non-interactive --workdir ~/Projects --no-start
EOF
}
default_config_dir() {
case "$OS_NAME" in
macOS)
printf '%s\n' "$HOME/Library/Application Support/MoviePilot"
;;
*)
printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/moviepilot"
;;
esac
}
setup_prompt_io() {
if [[ -t 0 && -t 1 ]]; then
HAS_TTY="true"
return
fi
if [[ -r /dev/tty && -w /dev/tty ]]; then
PROMPT_INPUT="/dev/tty"
PROMPT_OUTPUT="/dev/tty"
HAS_TTY="true"
fi
}
detect_os() {
local uname_s
uname_s="$(uname -s)"
@@ -64,6 +96,10 @@ detect_os() {
LINK_PATH="/usr/local/bin/moviepilot"
;;
esac
if [[ -z "$CONFIG_DIR" ]]; then
CONFIG_DIR="$(default_config_dir)"
fi
}
find_python() {
@@ -137,14 +173,15 @@ prompt_text() {
local answer=""
if [[ -n "$default_value" ]]; then
read -r -p "$label [$default_value]: " answer || true
if [[ -z "$answer" ]]; then
answer="$default_value"
fi
printf '%s [%s]: ' "$label" "$default_value" >"$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 <<EOF
系统环境: $OS_NAME
项目目录: $APP_DIR
配置目录: $CONFIG_DIR
Python 解释器: $PYTHON_BIN
CLI 命令: ${LINK_CLI:-false}
CLI 路径: ${LINK_PATH:-未创建}
@@ -308,7 +404,18 @@ CLI 路径: ${LINK_PATH:-未创建}
moviepilot status
moviepilot logs --frontend
moviepilot logs --stdio
moviepilot config path
完整 CLI 文档:
$APP_DIR/docs/cli.md
EOF
if [[ "$LINK_CLI" == "true" && "$(dirname "$LINK_PATH")" == "$HOME/.local/bin" ]]; then
echo
echo "PATH 说明:"
if [[ "$PATH_UPDATED" == "true" ]]; then
echo " 已将 ~/.local/bin 写入 $PATH_RC_FILE"
fi
echo " 如果当前终端仍提示找不到 moviepilot请重新打开终端或执行"
echo " source ${PATH_RC_FILE:-$HOME/.profile}"
fi

View File

@@ -3,15 +3,18 @@
from __future__ import annotations
import argparse
import asyncio
import getpass
import json
import os
import platform
import secrets
import shlex
import shutil
import subprocess
import sys
import tarfile
import uuid
import zipfile
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -19,16 +22,19 @@ from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[1]
CONFIG_DIR = ROOT / "config"
LEGACY_CONFIG_DIR = ROOT / "config"
HELPER_DIR = ROOT / "app" / "helper"
PUBLIC_DIR = ROOT / "public"
RUNTIME_DIR = ROOT / ".runtime"
NODE_DIR = RUNTIME_DIR / "node"
INSTALL_ENV_FILE = ROOT / ".moviepilot.env"
CONFIG_DIR = LEGACY_CONFIG_DIR
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"
@@ -56,6 +62,116 @@ NOTIFICATION_SWITCH_TYPES = [
]
def _default_config_dir() -> 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 <tag|branch>` 指定版本。")
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)