mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-22 07:54:06 +08:00
Refine agent skill boundaries and secret handling
This commit is contained in:
@@ -299,18 +299,14 @@ class PromptManager:
|
||||
api_port = settings.PORT
|
||||
api_path = settings.API_V1_STR
|
||||
|
||||
# API令牌
|
||||
api_token = settings.API_TOKEN or "未设置"
|
||||
|
||||
# 数据库信息
|
||||
# 数据库信息只保留非敏感连接摘要,凭据由内部工具自行读取。
|
||||
db_type = settings.DB_TYPE
|
||||
if db_type == "sqlite":
|
||||
db_info = f"SQLite ({settings.CONFIG_PATH / 'db' / 'moviepilot.db'})"
|
||||
db_info = "SQLite(本地配置目录数据库,路径由内部工具读取)"
|
||||
else:
|
||||
db_password = settings.DB_POSTGRESQL_PASSWORD or ""
|
||||
db_info = (
|
||||
f"PostgreSQL ({settings.DB_POSTGRESQL_USERNAME}:{db_password}@"
|
||||
f"{settings.DB_POSTGRESQL_TARGET}/{settings.DB_POSTGRESQL_DATABASE})"
|
||||
f"PostgreSQL({settings.DB_POSTGRESQL_TARGET}/"
|
||||
f"{settings.DB_POSTGRESQL_DATABASE},凭据由内部工具读取)"
|
||||
)
|
||||
|
||||
# 保留日期用于提供“今天是哪天”的稳定上下文,但不再注入秒级时间,
|
||||
@@ -322,7 +318,7 @@ class PromptManager:
|
||||
f"- IP地址: {ip_address}",
|
||||
f"- API端口: {api_port}",
|
||||
f"- API路径: {api_path}",
|
||||
f"- API令牌: {api_token}",
|
||||
"- API认证: 由内部工具自动处理,不在提示词中暴露令牌",
|
||||
f"- 外网域名: {settings.APP_DOMAIN or '未设置'}",
|
||||
f"- 数据库类型: {db_type}",
|
||||
f"- 数据库: {db_info}",
|
||||
|
||||
@@ -1,232 +1,217 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 2
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when you need to execute SQL against the MoviePilot database.
|
||||
This skill guides you through connecting to the database and executing SQL statements.
|
||||
The database type (SQLite or PostgreSQL) and connection details are provided in the system prompt <system_info>.
|
||||
Applicable scenarios include:
|
||||
1) The user asks about data statistics, counts, or aggregations that existing tools don't cover;
|
||||
2) The user wants to inspect, modify, or fix raw database records;
|
||||
3) The user asks to clean up data, update records, or perform database maintenance;
|
||||
4) The user asks questions like "how many downloads", "show me site stats", "delete old records", etc.
|
||||
allowed-tools: execute_command read_file
|
||||
Use this skill when you need to inspect, query, maintain, or carefully modify
|
||||
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
|
||||
which reads MoviePilot local settings itself and never requires database
|
||||
passwords or full PostgreSQL DSNs in the agent prompt. Applicable scenarios
|
||||
include data statistics, counts, aggregations, inspecting or fixing records,
|
||||
cleanup requests, and questions like "how many downloads", "show site stats",
|
||||
"delete old records", or "why is this subscription stuck".
|
||||
---
|
||||
|
||||
# Database Query (数据库查询)
|
||||
# Database Operation
|
||||
|
||||
This skill guides you through executing SQL against the MoviePilot database. Both read and write operations are supported.
|
||||
> All script paths are relative to this skill file.
|
||||
|
||||
## Prerequisites
|
||||
Use `scripts/mp-db.py` for all database access. Do not extract database passwords, API tokens, or full PostgreSQL DSNs from the prompt. The script reads MoviePilot local settings and connects to SQLite or PostgreSQL internally.
|
||||
|
||||
You need the following tools:
|
||||
- `execute_command` - Execute shell commands to run database queries. Use `action=run` when you need the query result immediately.
|
||||
## Scope And Boundaries
|
||||
|
||||
## Getting Database Connection Info
|
||||
This skill is the direct SQL boundary. It is implemented as a Python script and
|
||||
is appropriate when the agent must inspect records, run data statistics, repair
|
||||
stuck state, or perform an explicitly requested database update.
|
||||
|
||||
The system prompt `<system_info>` section already contains all the database connection details you need:
|
||||
- **数据库类型** — `sqlite` or `postgresql`
|
||||
- **数据库** — Full connection info:
|
||||
- For SQLite: the database file path, e.g. `SQLite (/config/db/moviepilot.db)`
|
||||
- For PostgreSQL: the connection string, e.g. `PostgreSQL (user:password@host:port/database)`
|
||||
Prefer safer product surfaces first:
|
||||
|
||||
**Do NOT run any detection commands.** Extract the database type and connection details directly from `<system_info>`.
|
||||
| Request | Preferred skill |
|
||||
|---|---|
|
||||
| Normal local MoviePilot product operation exposed as an MCP tool | `moviepilot-cli` |
|
||||
| Direct REST endpoint call | `moviepilot-api` |
|
||||
| Slash commands or plugin/system command dispatch | `command-dispatch` |
|
||||
| Manual file organization | `organize-files` |
|
||||
| Retry failed transfer history records | `transfer-failed-retry` |
|
||||
|
||||
## Executing Queries
|
||||
Use this skill as the final fallback for data access or mutation. It may run
|
||||
`SELECT`, `INSERT`, `UPDATE`, `DELETE`, and schema-changing statements through
|
||||
the bundled script, but broad or destructive writes still require explicit user
|
||||
authorization.
|
||||
|
||||
### SQLite Mode
|
||||
## Commands
|
||||
|
||||
Extract the database file path from `<system_info>` (the path inside the parentheses after `SQLite`).
|
||||
|
||||
Use `execute_command` with `action=run` to run queries:
|
||||
List tables:
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column <DB_PATH> "YOUR SQL QUERY HERE;"
|
||||
python scripts/mp-db.py tables
|
||||
```
|
||||
|
||||
For JSON-formatted output (easier to parse):
|
||||
Show table schema:
|
||||
|
||||
```bash
|
||||
sqlite3 -json <DB_PATH> "YOUR SQL QUERY HERE;"
|
||||
python scripts/mp-db.py schema downloadhistory
|
||||
```
|
||||
|
||||
**List all tables:**
|
||||
Run a read query:
|
||||
|
||||
```bash
|
||||
sqlite3 -header -column <DB_PATH> "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
|
||||
python scripts/mp-db.py query "SELECT COUNT(*) AS total FROM downloadhistory"
|
||||
```
|
||||
|
||||
**View table schema:**
|
||||
Read SQL from stdin or a file:
|
||||
|
||||
```bash
|
||||
sqlite3 <DB_PATH> ".schema tablename"
|
||||
python scripts/mp-db.py query --file /path/to/query.sql
|
||||
```
|
||||
|
||||
### PostgreSQL Mode
|
||||
|
||||
Extract the connection parameters from `<system_info>` (parse `user:password@host:port/database` from the parentheses after `PostgreSQL`).
|
||||
|
||||
Use `execute_command` with `action=run` to run queries via `psql`:
|
||||
Run a write statement:
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "YOUR SQL QUERY HERE;"
|
||||
python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
```
|
||||
|
||||
**List all tables:**
|
||||
`query --write` is also supported for compatibility, but prefer the `write` subcommand for `INSERT`, `UPDATE`, `DELETE`, and schema changes.
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"
|
||||
```
|
||||
## Workflow
|
||||
|
||||
**View table schema:**
|
||||
1. Prefer existing MoviePilot tools or APIs for normal product workflows.
|
||||
2. Use this skill for direct database inspection only when no existing tool covers the request.
|
||||
3. For unknown schema, run `tables` first, then `schema <table>`.
|
||||
4. For `SELECT` queries, execute directly with a narrow projection and an explicit `LIMIT` when reading rows.
|
||||
5. For `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `TRUNCATE`, `CREATE`, or `REPLACE`, use `write` and report the affected row count.
|
||||
|
||||
```bash
|
||||
PGPASSWORD=<password> psql -h <host> -p <port> -U <user> -d <database> -c "\d tablename"
|
||||
```
|
||||
## Built-in Safety
|
||||
|
||||
## Interpret Results
|
||||
|
||||
After executing the query, analyze the results and present them in a clear, user-friendly format. Use aggregation, sorting, and filtering as needed.
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
MoviePilot uses the following core tables:
|
||||
|
||||
### downloadhistory (下载历史)
|
||||
Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubanid`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
|
||||
|
||||
### downloadfiles (下载文件)
|
||||
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
|
||||
|
||||
### transferhistory (整理历史)
|
||||
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status` (boolean: true=success, false=failed), `errmsg`, `date`
|
||||
|
||||
### subscribe (订阅)
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state` ('N'=new, 'R'=running, 'S'=paused), `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
|
||||
|
||||
### subscribehistory (订阅历史)
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
|
||||
|
||||
### user (用户)
|
||||
Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings`
|
||||
|
||||
### site (站点)
|
||||
Key columns: `id`, `name`, `domain`, `url`, `pri` (priority), `cookie`, `proxy`, `is_active`, `downloader`, `limit_interval`, `limit_count`
|
||||
|
||||
### siteuserdata (站点用户数据)
|
||||
Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload`, `download`, `ratio`, `seeding`, `leeching`, `seeding_size`, `updated_day`
|
||||
|
||||
### sitestatistic (站点统计)
|
||||
Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`
|
||||
|
||||
### mediaserveritem (媒体库条目)
|
||||
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `tmdbid`, `imdbid`, `tvdbid`, `path`
|
||||
|
||||
### systemconfig (系统配置)
|
||||
Key columns: `id`, `key`, `value` (JSON)
|
||||
|
||||
### userconfig (用户配置)
|
||||
Key columns: `id`, `username`, `key`, `value` (JSON)
|
||||
|
||||
### plugindata (插件数据)
|
||||
Key columns: `id`, `plugin_id`, `key`, `value` (JSON)
|
||||
|
||||
### message (消息)
|
||||
Key columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`
|
||||
|
||||
### workflow (工作流)
|
||||
Key columns: `id`, `name`, `description`, `timer`, `trigger_type`, `event_type`, `state` ('W'=waiting, 'R'=running), `run_count`, `actions`, `flows`, `last_time`
|
||||
|
||||
### passkey (通行密钥)
|
||||
Key columns: `id`, `user_id`, `credential_id`, `public_key`, `name`, `created_at`, `last_used_at`, `is_active`
|
||||
|
||||
### siteicon (站点图标)
|
||||
Key columns: `id`, `name`, `domain`, `url`, `base64`
|
||||
|
||||
## Common Query Examples
|
||||
|
||||
### Count total downloads
|
||||
```sql
|
||||
SELECT COUNT(*) AS total FROM downloadhistory;
|
||||
```
|
||||
|
||||
### Recent download history
|
||||
```sql
|
||||
SELECT title, year, type, torrent_site, date FROM downloadhistory ORDER BY id DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Failed transfers
|
||||
```sql
|
||||
SELECT id, title, src, errmsg, date FROM transferhistory WHERE status = 0 ORDER BY id DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Active subscriptions
|
||||
```sql
|
||||
SELECT name, year, type, season, state, lack_episode FROM subscribe WHERE state = 'R';
|
||||
```
|
||||
|
||||
### Site upload/download statistics
|
||||
```sql
|
||||
SELECT name, domain, upload, download, ratio, bonus, seeding, user_level FROM siteuserdata ORDER BY upload DESC;
|
||||
```
|
||||
|
||||
### Media library statistics
|
||||
```sql
|
||||
SELECT server, library, COUNT(*) AS count FROM mediaserveritem GROUP BY server, library;
|
||||
```
|
||||
|
||||
### Site access success rate
|
||||
```sql
|
||||
SELECT domain, success, fail, ROUND(success * 100.0 / (success + fail), 1) AS success_rate FROM sitestatistic WHERE success + fail > 0 ORDER BY success_rate DESC;
|
||||
```
|
||||
|
||||
### Plugin data inspection
|
||||
```sql
|
||||
SELECT plugin_id, key FROM plugindata ORDER BY plugin_id, key;
|
||||
```
|
||||
|
||||
### Delete old download history (write operation)
|
||||
```sql
|
||||
DELETE FROM downloadhistory WHERE date < '2024-01-01';
|
||||
```
|
||||
|
||||
### Update subscription state (write operation)
|
||||
```sql
|
||||
UPDATE subscribe SET state = 'S' WHERE id = 123;
|
||||
```
|
||||
|
||||
### Clean up failed transfer records (write operation)
|
||||
```sql
|
||||
DELETE FROM transferhistory WHERE status = 0 AND date < '2024-06-01';
|
||||
```
|
||||
- `query` defaults to read-only mode.
|
||||
- `write` executes data updates and schema-changing statements directly.
|
||||
- `query --write` remains available as a compatibility alias for write statements.
|
||||
- Multiple SQL statements in one invocation are rejected.
|
||||
- Plain `SELECT` queries get a default `LIMIT 100` if no limit is present.
|
||||
- Query results are returned exactly as stored. The agent may use sensitive values internally when needed, but must not echo secrets in the final user-facing response unless the user explicitly asks to inspect that value.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
1. **Confirm before writing** — For any `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, or `TRUNCATE` operation, always describe what the statement will do and ask the user to confirm before executing. For `SELECT` queries, execute directly without confirmation
|
||||
2. **Back up before destructive operations** — Before executing `DELETE`, `DROP`, or `TRUNCATE` on important tables, suggest the user back up the data first (e.g., export with `.dump` for SQLite or `pg_dump` for PostgreSQL)
|
||||
3. **Use WHERE clauses** — Never run `UPDATE` or `DELETE` without a `WHERE` clause unless the user explicitly intends to affect all rows
|
||||
4. **Use LIMIT for queries** — When querying large tables with `SELECT`, add `LIMIT` to prevent excessive output
|
||||
5. **Sensitive data** — The `site` table contains `cookie`, `apikey`, and `token` fields. NEVER display these values to the user. Exclude them from SELECT or replace with `'***'`
|
||||
6. **Password data** — The `user` table contains `hashed_password` and `otp_secret` fields. NEVER display these values
|
||||
7. **Output limits** — If the query results are very long, summarize or truncate them
|
||||
1. Confirm before destructive or broad write operations when the user has not already clearly authorized the exact change.
|
||||
2. Suggest a backup before destructive operations such as `DELETE`, `DROP`, or `TRUNCATE`.
|
||||
3. Never run `UPDATE` or `DELETE` without a `WHERE` clause unless the user explicitly intends to affect all rows.
|
||||
4. Raw secrets, cookies, passkeys, hashed passwords, OTP secrets, API keys, or tokens may appear in tool output. Use them only for the requested operation and avoid repeating them in the final response unless explicitly requested.
|
||||
5. Keep output small. Summarize large results instead of dumping them.
|
||||
|
||||
## SQL Dialect Differences
|
||||
## Core Tables
|
||||
|
||||
When writing queries, be aware of differences between SQLite and PostgreSQL:
|
||||
### downloadhistory
|
||||
Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubanid`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
|
||||
|
||||
### downloadfiles
|
||||
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
|
||||
|
||||
### transferhistory
|
||||
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date`
|
||||
|
||||
### subscribe
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
|
||||
|
||||
### subscribehistory
|
||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
|
||||
|
||||
### user
|
||||
Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings`
|
||||
|
||||
### site
|
||||
Key columns: `id`, `name`, `domain`, `url`, `pri`, `cookie`, `proxy`, `is_active`, `downloader`, `limit_interval`, `limit_count`
|
||||
|
||||
### siteuserdata
|
||||
Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload`, `download`, `ratio`, `seeding`, `leeching`, `seeding_size`, `updated_day`
|
||||
|
||||
### sitestatistic
|
||||
Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`
|
||||
|
||||
### mediaserveritem
|
||||
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `tmdbid`, `imdbid`, `tvdbid`, `path`
|
||||
|
||||
### systemconfig
|
||||
Key columns: `id`, `key`, `value`
|
||||
|
||||
### userconfig
|
||||
Key columns: `id`, `username`, `key`, `value`
|
||||
|
||||
### plugindata
|
||||
Key columns: `id`, `plugin_id`, `key`, `value`
|
||||
|
||||
### message
|
||||
Key columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`
|
||||
|
||||
### workflow
|
||||
Key columns: `id`, `name`, `description`, `timer`, `trigger_type`, `event_type`, `state`, `run_count`, `actions`, `flows`, `last_time`
|
||||
|
||||
### passkey
|
||||
Key columns: `id`, `user_id`, `credential_id`, `public_key`, `name`, `created_at`, `last_used_at`, `is_active`
|
||||
|
||||
### siteicon
|
||||
Key columns: `id`, `name`, `domain`, `url`, `base64`
|
||||
|
||||
## Common Queries
|
||||
|
||||
Total downloads:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) AS total FROM downloadhistory
|
||||
```
|
||||
|
||||
Recent download history:
|
||||
|
||||
```sql
|
||||
SELECT title, year, type, torrent_site, date FROM downloadhistory ORDER BY id DESC LIMIT 10
|
||||
```
|
||||
|
||||
Failed transfers:
|
||||
|
||||
```sql
|
||||
SELECT id, title, src, errmsg, date FROM transferhistory WHERE status = 0 ORDER BY id DESC LIMIT 10
|
||||
```
|
||||
|
||||
Active subscriptions:
|
||||
|
||||
```sql
|
||||
SELECT name, year, type, season, state, lack_episode FROM subscribe WHERE state = 'R' LIMIT 50
|
||||
```
|
||||
|
||||
Site upload/download statistics:
|
||||
|
||||
```sql
|
||||
SELECT name, domain, upload, download, ratio, bonus, seeding, user_level FROM siteuserdata ORDER BY upload DESC LIMIT 50
|
||||
```
|
||||
|
||||
Media library statistics:
|
||||
|
||||
```sql
|
||||
SELECT server, library, COUNT(*) AS count FROM mediaserveritem GROUP BY server, library
|
||||
```
|
||||
|
||||
Site access success rate:
|
||||
|
||||
```sql
|
||||
SELECT domain, success, fail, ROUND(success * 100.0 / (success + fail), 1) AS success_rate FROM sitestatistic WHERE success + fail > 0 ORDER BY success_rate DESC LIMIT 50
|
||||
```
|
||||
|
||||
Plugin data keys:
|
||||
|
||||
```sql
|
||||
SELECT plugin_id, key FROM plugindata ORDER BY plugin_id, key LIMIT 100
|
||||
```
|
||||
|
||||
## SQL Dialect Notes
|
||||
|
||||
| Feature | SQLite | PostgreSQL |
|
||||
|---------|--------|------------|
|
||||
|---|---|---|
|
||||
| Boolean values | `0` / `1` | `false` / `true` |
|
||||
| String concat | `\|\|` | `\|\|` or `CONCAT()` |
|
||||
| String concat | `||` | `||` or `CONCAT()` |
|
||||
| Current time | `datetime('now')` | `NOW()` |
|
||||
| LIMIT syntax | `LIMIT n` | `LIMIT n` |
|
||||
| JSON access | `json_extract(col, '$.key')` | `col->>'key'` |
|
||||
| Case sensitivity | Case-insensitive by default | Case-sensitive |
|
||||
| LIKE | Case-insensitive | Use `ILIKE` for case-insensitive |
|
||||
| Case-insensitive match | `LIKE` | `ILIKE` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **sqlite3 not found**: The `sqlite3` CLI should be pre-installed in the MoviePilot Docker container. If missing, you can try using Python: `python3 -c "import sqlite3; ..."`
|
||||
- **psql not found**: For PostgreSQL, if `psql` is not available, use Python: `python3 -c "import psycopg2; ..."`
|
||||
- **Permission denied**: Database queries require admin privileges
|
||||
- **Table not found**: Use the "list all tables" query first to verify table names
|
||||
- Missing dependency: run inside the MoviePilot project environment so SQLAlchemy and database drivers are available.
|
||||
- Connection failure: verify MoviePilot config with `moviepilot doctor`.
|
||||
- Table not found: run `python scripts/mp-db.py tables`, then inspect the table with `schema`.
|
||||
|
||||
228
skills/database-operation/scripts/mp-db.py
Normal file
228
skills/database-operation/scripts/mp-db.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MoviePilot 数据库操作脚本。
|
||||
|
||||
脚本从项目配置读取数据库连接参数,不要求 Agent 在提示词中接触数据库密码。
|
||||
默认只允许查询语句;写操作必须显式传入 --write。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
PROJECT_ROOT = SCRIPT_PATH.parents[3]
|
||||
WRITE_STATEMENT_RE = re.compile(
|
||||
r"^\s*(insert|update|delete|drop|alter|truncate|create|replace)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
WRITE_KEYWORD_RE = re.compile(
|
||||
r"\b(insert|update|delete|drop|alter|truncate|create|replace)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SELECT_STATEMENT_RE = re.compile(r"^\s*(select|with|explain)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _ensure_project_import() -> None:
|
||||
"""确保脚本可以从任意工作目录导入 MoviePilot 项目模块。"""
|
||||
project_path = str(PROJECT_ROOT)
|
||||
if project_path not in sys.path:
|
||||
sys.path.insert(0, project_path)
|
||||
|
||||
|
||||
def _load_settings() -> Any:
|
||||
"""读取 MoviePilot 运行配置。"""
|
||||
_ensure_project_import()
|
||||
from app.core.config import settings # pylint: disable=import-outside-toplevel
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _build_engine() -> Engine:
|
||||
"""根据 MoviePilot 配置创建同步数据库引擎。"""
|
||||
settings = _load_settings()
|
||||
if str(settings.DB_TYPE).lower() == "postgresql":
|
||||
return create_engine(
|
||||
settings.DB_POSTGRESQL_URL(),
|
||||
pool_pre_ping=settings.DB_POOL_PRE_PING,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
)
|
||||
return create_engine(
|
||||
f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||
pool_pre_ping=settings.DB_POOL_PRE_PING,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
connect_args={"timeout": settings.DB_TIMEOUT},
|
||||
)
|
||||
|
||||
|
||||
def _normalize_sql(sql: str) -> str:
|
||||
"""去除 SQL 首尾空白和末尾分号。"""
|
||||
return sql.strip().rstrip(";")
|
||||
|
||||
|
||||
def _contains_multiple_statements(sql: str) -> bool:
|
||||
"""判断 SQL 是否包含多语句分隔符。"""
|
||||
return ";" in sql
|
||||
|
||||
|
||||
def _is_write_statement(sql: str) -> bool:
|
||||
"""判断 SQL 是否为写操作或结构变更操作。"""
|
||||
return bool(WRITE_STATEMENT_RE.match(sql))
|
||||
|
||||
|
||||
def _is_supported_statement(sql: str, allow_write: bool) -> bool:
|
||||
"""判断 SQL 是否在当前权限模式下允许执行。"""
|
||||
if _contains_multiple_statements(sql):
|
||||
return False
|
||||
if allow_write:
|
||||
return True
|
||||
return bool(SELECT_STATEMENT_RE.match(sql)) and not WRITE_KEYWORD_RE.search(sql)
|
||||
|
||||
|
||||
def _append_limit(sql: str, limit: int) -> str:
|
||||
"""为普通 SELECT 查询追加默认 LIMIT,避免输出过大。"""
|
||||
if limit <= 0:
|
||||
return sql
|
||||
lowered = sql.lower()
|
||||
if not lowered.lstrip().startswith("select"):
|
||||
return sql
|
||||
if re.search(r"\blimit\s+\d+\b", lowered):
|
||||
return sql
|
||||
return f"{sql} LIMIT {limit}"
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""将 SQLAlchemy 行对象转为普通字典。"""
|
||||
return dict(row._mapping)
|
||||
|
||||
|
||||
def _print_json(payload: Any) -> None:
|
||||
"""输出 JSON 结果。"""
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
def list_tables() -> int:
|
||||
"""列出当前数据库中的数据表。"""
|
||||
engine = _build_engine()
|
||||
inspector = inspect(engine)
|
||||
_print_json({"tables": sorted(inspector.get_table_names())})
|
||||
return 0
|
||||
|
||||
|
||||
def show_schema(table_name: str) -> int:
|
||||
"""显示指定数据表的字段结构。"""
|
||||
engine = _build_engine()
|
||||
inspector = inspect(engine)
|
||||
columns = [
|
||||
{
|
||||
"name": column.get("name"),
|
||||
"type": str(column.get("type")),
|
||||
"nullable": column.get("nullable"),
|
||||
"default": column.get("default"),
|
||||
"primary_key": bool(column.get("primary_key")),
|
||||
}
|
||||
for column in inspector.get_columns(table_name)
|
||||
]
|
||||
_print_json({"table": table_name, "columns": columns})
|
||||
return 0
|
||||
|
||||
|
||||
def run_query(sql: str, *, limit: int = 100, allow_write: bool = False) -> int:
|
||||
"""
|
||||
执行 SQL 语句并输出 JSON 结果。
|
||||
|
||||
:param sql: 要执行的 SQL 语句
|
||||
:param limit: 查询语句默认追加的最大行数
|
||||
:param allow_write: 是否允许写操作或结构变更操作
|
||||
:return: 进程退出码
|
||||
"""
|
||||
normalized_sql = _normalize_sql(sql)
|
||||
if not normalized_sql:
|
||||
print("Error: SQL is empty", file=sys.stderr)
|
||||
return 1
|
||||
if not _is_supported_statement(normalized_sql, allow_write):
|
||||
print("Error: write statements require --write", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
statement_sql = _append_limit(normalized_sql, limit)
|
||||
engine = _build_engine()
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
result = connection.execute(text(statement_sql))
|
||||
if result.returns_rows:
|
||||
rows = [_row_to_dict(row) for row in result.fetchall()]
|
||||
_print_json({"rows": rows, "row_count": len(rows)})
|
||||
else:
|
||||
_print_json({"row_count": result.rowcount})
|
||||
except SQLAlchemyError as err:
|
||||
print(f"Error: {err}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _read_sql(sql: Optional[str], sql_file: Optional[str]) -> str:
|
||||
"""从参数或文件读取 SQL 文本。"""
|
||||
if sql:
|
||||
return sql
|
||||
if sql_file:
|
||||
return Path(sql_file).read_text(encoding="utf-8")
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.read()
|
||||
return ""
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="MoviePilot database operation helper")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
query_parser = subparsers.add_parser("query", help="execute a SQL statement")
|
||||
query_parser.add_argument("sql", nargs="?", help="SQL statement")
|
||||
query_parser.add_argument("--file", dest="sql_file", help="read SQL from file")
|
||||
query_parser.add_argument("--limit", type=int, default=100, help="default SELECT row limit")
|
||||
query_parser.add_argument("--write", action="store_true", help="allow write statements")
|
||||
|
||||
write_parser = subparsers.add_parser("write", help="execute a write statement")
|
||||
write_parser.add_argument("sql", nargs="?", help="SQL statement")
|
||||
write_parser.add_argument("--file", dest="sql_file", help="read SQL from file")
|
||||
|
||||
subparsers.add_parser("tables", help="list tables")
|
||||
|
||||
schema_parser = subparsers.add_parser("schema", help="show table schema")
|
||||
schema_parser.add_argument("table_name", help="table name")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行命令行入口。"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "tables":
|
||||
return list_tables()
|
||||
if args.command == "schema":
|
||||
return show_schema(args.table_name)
|
||||
if args.command == "query":
|
||||
sql = _read_sql(args.sql, args.sql_file)
|
||||
return run_query(sql, limit=args.limit, allow_write=args.write)
|
||||
if args.command == "write":
|
||||
sql = _read_sql(args.sql, args.sql_file)
|
||||
return run_query(sql, limit=0, allow_write=True)
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,7 +1,14 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 1
|
||||
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 245 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
|
||||
version: 2
|
||||
description: >-
|
||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||
search, downloads, subscriptions, library management, site management, system
|
||||
administration, plugins, workflows, and more. Prefer `moviepilot-cli` for
|
||||
normal local MCP tool workflows; use this skill when the user explicitly asks
|
||||
for HTTP API access, when an endpoint is not exposed as an MCP tool, or when
|
||||
running in an environment where direct REST calls are the appropriate bridge.
|
||||
---
|
||||
|
||||
# MoviePilot REST API
|
||||
@@ -10,15 +17,38 @@ description: Use this skill when you need to call MoviePilot REST API endpoints
|
||||
|
||||
Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly.
|
||||
|
||||
## Scope And Boundaries
|
||||
|
||||
This skill is the REST API bridge. It is implemented as a Python script and is
|
||||
useful when the agent needs endpoint-level coverage beyond the local
|
||||
`moviepilot tool` MCP CLI.
|
||||
|
||||
Choose other skills first when they match more precisely:
|
||||
|
||||
| Request | Preferred skill |
|
||||
|---|---|
|
||||
| Normal local MoviePilot product operation exposed as an MCP tool | `moviepilot-cli` |
|
||||
| Direct SQL query or database update | `database-operation` |
|
||||
| Restart, version check, or upgrade | `moviepilot-update` |
|
||||
| Slash commands or plugin/system command dispatch | `command-dispatch` |
|
||||
| Browser-only state, site login pages, screenshots, cookies | `browser-use` |
|
||||
|
||||
Do not use this skill just because MoviePilot is mentioned. Use it when the
|
||||
task specifically needs a REST endpoint, token-query endpoint, or API behavior
|
||||
that the CLI/MCP tools do not expose.
|
||||
|
||||
## Setup
|
||||
|
||||
Configure the backend host and API key (persisted to `~/.config/moviepilot_api/config`):
|
||||
When the script runs inside the MoviePilot project, it imports `app.core.config.settings` and reads `settings.HOST`, `settings.PORT`, and `settings.API_TOKEN` directly. Do not ask the user for `API_TOKEN`, and do not copy API keys into the prompt.
|
||||
|
||||
```
|
||||
python scripts/mp-api.py configure --host http://localhost:3000 --apikey <API_TOKEN>
|
||||
```
|
||||
Configuration priority:
|
||||
|
||||
The API key is the `API_TOKEN` value from MoviePilot settings.
|
||||
1. CLI flags: `--host`, `--apikey`
|
||||
2. Environment variables: `MP_HOST`, `MP_API_KEY`
|
||||
3. Local MoviePilot settings
|
||||
4. Legacy config file: `~/.config/moviepilot_api/config`
|
||||
|
||||
Use `configure` only as a legacy fallback outside the MoviePilot project, and avoid it in normal agent workflows because it persists a long-lived API key to disk.
|
||||
|
||||
## How to Call APIs
|
||||
|
||||
@@ -30,9 +60,10 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
|
||||
|
||||
### Authentication
|
||||
|
||||
- By default, the key is sent via the `X-API-KEY` header.
|
||||
- By default, the script auto-loads the local key and sends it via the `X-API-KEY` header.
|
||||
- For endpoints suffixed with `2` (e.g. `/api/v1/dashboard/statistic2`), use `--token-param` to send the key as `?token=`.
|
||||
- Both methods validate against the same `API_TOKEN` value.
|
||||
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -564,9 +595,9 @@ python scripts/mp-api.py GET /api/v1/site/cookiecloud
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| HTTP 401 | API key is invalid or missing. Re-run `configure` with correct `--apikey`. |
|
||||
| HTTP 401 | API key is invalid or missing. Verify local settings with `moviepilot doctor`; only use `--apikey` as an external fallback. |
|
||||
| HTTP 403 | Insufficient permissions. The API key grants superuser access; check if the endpoint requires special auth. |
|
||||
| HTTP 404 | Endpoint or resource not found. Verify the path and path parameters. |
|
||||
| HTTP 422 | Validation error. Check required parameters and JSON body format. |
|
||||
| Connection error | Verify `--host` URL is reachable. Check if MoviePilot is running. |
|
||||
| Missing config | Run `python scripts/mp-api.py configure --host <HOST> --apikey <KEY>` first. |
|
||||
| Missing config | Run inside the MoviePilot project, or set `MP_HOST` and `MP_API_KEY` in the process environment. |
|
||||
|
||||
@@ -14,7 +14,7 @@ Authentication:
|
||||
It can also fall back to ``?token=`` for endpoints that require it.
|
||||
|
||||
Configuration priority:
|
||||
CLI flags > Environment variables (MP_HOST / MP_API_KEY) > Config file
|
||||
CLI flags > Environment variables > local MoviePilot settings > Config file
|
||||
|
||||
Config file location: ~/.config/moviepilot_api/config
|
||||
"""
|
||||
@@ -23,17 +23,20 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import ssl
|
||||
import stat
|
||||
import urllib.request
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import ssl
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = os.path.basename(sys.argv[0]) if sys.argv else "mp-api.py"
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
PROJECT_ROOT = SCRIPT_PATH.parents[3]
|
||||
CONFIG_DIR = Path.home() / ".config" / "moviepilot_api"
|
||||
CONFIG_FILE = CONFIG_DIR / "config"
|
||||
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration helpers
|
||||
@@ -63,19 +66,53 @@ def read_config() -> tuple[str, str]:
|
||||
|
||||
|
||||
def save_config(host: str, apikey: str) -> None:
|
||||
"""Persist host and API key to the legacy config file."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(f"MP_HOST={host}\nMP_API_KEY={apikey}\n", encoding="utf-8")
|
||||
CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
|
||||
def _ensure_project_import() -> None:
|
||||
"""Add the MoviePilot project root to sys.path for local auto-configuration."""
|
||||
project_path = str(PROJECT_ROOT)
|
||||
if project_path not in sys.path:
|
||||
sys.path.insert(0, project_path)
|
||||
|
||||
|
||||
def _client_host(host: str) -> str:
|
||||
"""Return a loopback host usable by local clients."""
|
||||
host = (host or "").strip()
|
||||
if host in LOCAL_HOSTS:
|
||||
return "127.0.0.1"
|
||||
return host
|
||||
|
||||
|
||||
def read_local_config() -> tuple[str, str]:
|
||||
"""Return host and key from local MoviePilot settings when available."""
|
||||
try:
|
||||
_ensure_project_import()
|
||||
from app.core.config import settings # pylint: disable=import-outside-toplevel
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
host = str(settings.HOST or "")
|
||||
port = settings.PORT
|
||||
apikey = str(settings.API_TOKEN or "")
|
||||
if host and port:
|
||||
return f"http://{_client_host(host)}:{port}", apikey
|
||||
|
||||
return "", apikey
|
||||
|
||||
|
||||
def resolve_config(
|
||||
cli_host: str = "",
|
||||
cli_key: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve effective host & key using priority: CLI > env > file."""
|
||||
"""Resolve effective host and key without requiring prompt-visible secrets."""
|
||||
local_host, local_key = read_local_config()
|
||||
cfg_host, cfg_key = read_config()
|
||||
host = cli_host or os.environ.get("MP_HOST", "") or cfg_host
|
||||
apikey = cli_key or os.environ.get("MP_API_KEY", "") or cfg_key
|
||||
host = cli_host or os.environ.get("MP_HOST", "") or local_host or cfg_host
|
||||
apikey = cli_key or os.environ.get("MP_API_KEY", "") or local_key or cfg_key
|
||||
return host, apikey
|
||||
|
||||
|
||||
@@ -199,11 +236,11 @@ def print_json(obj: object) -> None:
|
||||
|
||||
def print_usage() -> None:
|
||||
print(f"""Usage: python {SCRIPT_NAME} [options] <METHOD> <PATH> [key=value ...] [--json '<body>']
|
||||
python {SCRIPT_NAME} configure --host <HOST> --apikey <KEY>
|
||||
python {SCRIPT_NAME} configure --host <HOST> --apikey <KEY> # legacy fallback
|
||||
|
||||
Options:
|
||||
--host HOST MoviePilot backend URL
|
||||
--apikey KEY API key (API_TOKEN)
|
||||
--host HOST MoviePilot backend URL (auto-read locally when omitted)
|
||||
--apikey KEY API key (auto-read locally when omitted)
|
||||
--token-param Send key as ?token= query param instead of X-API-KEY header
|
||||
--timeout SECS Request timeout (default: 120)
|
||||
--help Show this help message
|
||||
@@ -211,8 +248,6 @@ Options:
|
||||
Methods: GET POST PUT DELETE
|
||||
|
||||
Examples:
|
||||
python {SCRIPT_NAME} configure --host http://localhost:3000 --apikey mytoken123
|
||||
|
||||
python {SCRIPT_NAME} GET /api/v1/media/search title="Avatar" type="movie"
|
||||
python {SCRIPT_NAME} GET /api/v1/subscribe/
|
||||
python {SCRIPT_NAME} POST /api/v1/download/add --json '{{"torrent_url":"abc:1"}}'
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
---
|
||||
name: moviepilot-cli
|
||||
version: 1
|
||||
description: Use this skill for any request involving movies, TV shows, or anime, including searching, downloads, subscriptions, library management. Also use this skill whenever the user explicitly mentions MoviePilot.
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when the user asks to operate MoviePilot through the local
|
||||
`moviepilot tool` MCP CLI for normal product workflows: media search, torrent
|
||||
search, downloads, subscriptions, downloader tasks, library checks, sites,
|
||||
schedulers, workflows, and messages. Prefer dedicated skills for slash command
|
||||
dispatch, manual file organization or failed transfer retry, direct REST API
|
||||
calls, direct database SQL, browser operations, and restart/upgrade.
|
||||
---
|
||||
|
||||
# MoviePilot CLI
|
||||
|
||||
> All script paths are relative to this skill file.
|
||||
|
||||
Use `scripts/mp-cli.js` to interact with the MoviePilot backend.
|
||||
Use local `moviepilot tool ...` commands to interact with MoviePilot MCP tools.
|
||||
The command reads the local MoviePilot configuration; do not ask the user for
|
||||
`API_TOKEN`, database passwords, or a backend DSN during normal local use.
|
||||
|
||||
## Scope And Boundaries
|
||||
|
||||
This skill is for normal MoviePilot product operations exposed as MCP tools.
|
||||
Choose other skills first when they match more precisely:
|
||||
|
||||
| Request | Preferred skill |
|
||||
|---|---|
|
||||
| Slash commands or plugin/system command dispatch | `command-dispatch` |
|
||||
| Manual file organization | `organize-files` |
|
||||
| Retry failed transfer history records | `transfer-failed-retry` |
|
||||
| Direct REST endpoint not exposed by MCP tools | `moviepilot-api` |
|
||||
| Direct SQL query or database update | `database-operation` |
|
||||
| Restart, version check, or upgrade | `moviepilot-update` |
|
||||
| Browser-only state, site login pages, screenshots, cookies | `browser-use` |
|
||||
|
||||
Use `moviepilot-api` only after `moviepilot tool list` and
|
||||
`moviepilot tool show <command>` confirm that no MCP tool covers the required
|
||||
operation. Use `database-operation` only when the task explicitly requires SQL
|
||||
inspection or mutation, or when product tools/API cannot answer the data
|
||||
question.
|
||||
|
||||
## Discover Commands
|
||||
|
||||
List all available commands: `node scripts/mp-cli.js list`
|
||||
List all available commands: `moviepilot tool list`
|
||||
|
||||
Show parameters and usage for a specific command: `node scripts/mp-cli.js show <command>`
|
||||
Show parameters and usage for a specific command: `moviepilot tool show <command>`
|
||||
|
||||
Always run `show <command>` before calling a command — parameter names are not inferable, do not guess.
|
||||
|
||||
@@ -38,7 +67,7 @@ Always run `show <command>` before calling a command — parameter names are not
|
||||
#### 1. Search TMDB
|
||||
|
||||
Search for a movie or TV show by title:
|
||||
`node scripts/mp-cli.js search_media title="..." media_type="movie"`
|
||||
`moviepilot tool run search_media title="..." media_type="movie"`
|
||||
|
||||
If the user specifies a TV season, run Season Validation step first — the season number provided by the user may not match TMDB.
|
||||
|
||||
@@ -47,13 +76,13 @@ If the user specifies a TV season, run Season Validation step first — the seas
|
||||
Prefer `tmdb_id`; use `douban_id` only when `tmdb_id` is unavailable.
|
||||
|
||||
Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs:
|
||||
`node scripts/mp-cli.js query_sites`
|
||||
`moviepilot tool run query_sites`
|
||||
|
||||
Search torrents using default sites:
|
||||
`node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie"`
|
||||
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie"`
|
||||
|
||||
Search torrents using user-specified sites (pass site IDs from `query_sites`):
|
||||
`node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" sites='1,3'`
|
||||
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie" sites='1,3'`
|
||||
|
||||
When `search_torrents` returns:
|
||||
1. **Stop** — do not call `get_search_results` yet.
|
||||
@@ -63,12 +92,12 @@ When `search_torrents` returns:
|
||||
|
||||
#### 3. Get filtered results (only after user has responded to filter_options)
|
||||
|
||||
Run `node scripts/mp-cli.js show get_search_results` to check available parameters. Filter logic: OR within a field, AND across fields.
|
||||
Run `moviepilot tool show get_search_results` to check available parameters. Filter logic: OR within a field, AND across fields.
|
||||
|
||||
Filter values must come from the `filter_options` returned by `search_torrents` — do not invent, translate, normalize, or use values from any other source. Note: `filter_options` keys are camelCase (e.g., `freeState`), but `get_search_results` params are snake_case (e.g., `free_state`).
|
||||
|
||||
Fetch results with selected filters:
|
||||
`node scripts/mp-cli.js get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
|
||||
If empty, tell the user which filter to relax and ask before retrying.
|
||||
|
||||
@@ -95,7 +124,7 @@ If the media already exists in the library or is already subscribed, **stop** an
|
||||
#### 6. Add download
|
||||
|
||||
Download one or more torrents (`torrent_url` comes from `get_search_results` output):
|
||||
`node scripts/mp-cli.js add_download_tasks torrent_url="abc1234:1,def5678:2"`
|
||||
`moviepilot tool run add_download_tasks torrent_url="abc1234:1,def5678:2"`
|
||||
|
||||
#### Error handling
|
||||
|
||||
@@ -113,59 +142,59 @@ Download one or more torrents (`torrent_url` comes from `get_search_results` out
|
||||
3. If the user specifies a TV season, run Season Validation step first.
|
||||
|
||||
Subscribe to a movie or TV show:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009`
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009`
|
||||
|
||||
Subscribe to a specific season:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4`
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4`
|
||||
|
||||
Subscribe starting from a specific episode:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
|
||||
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
|
||||
|
||||
### Manage Downloads
|
||||
|
||||
List download tasks and get hash for further operations:
|
||||
`node scripts/mp-cli.js query_download_tasks status=downloading`
|
||||
`moviepilot tool run query_download_tasks status=downloading`
|
||||
|
||||
Use `status=completed` for tasks that are neither downloading nor paused in the downloader; use `status=all` to include every MoviePilot-tagged downloader task. Add `include_all_tags=true` when diagnosing tasks that do not have the MoviePilot built-in tag. Add `include_trackers=true` or query by `hash` when tracker URLs are needed.
|
||||
|
||||
Update a download task (supports start/stop, tags, speed limits, trackers, save path, category, ratio, and seeding time where the downloader supports them):
|
||||
`node scripts/mp-cli.js update_download_tasks hash=<hash> action=stop upload_limit=512 download_limit=2048`
|
||||
`moviepilot tool run update_download_tasks hash=<hash> action=stop upload_limit=512 download_limit=2048`
|
||||
|
||||
Add trackers to a download task:
|
||||
`node scripts/mp-cli.js update_download_tasks hash=<hash> trackers='https://tracker.example/announce,udp://tracker.example:80/announce'`
|
||||
`moviepilot tool run update_download_tasks hash=<hash> trackers='https://tracker.example/announce,udp://tracker.example:80/announce'`
|
||||
|
||||
Delete a download task (confirm with user first — irreversible):
|
||||
`node scripts/mp-cli.js delete_download_tasks hash=<hash>`
|
||||
`moviepilot tool run delete_download_tasks hash=<hash>`
|
||||
|
||||
Delete a download task and also remove its files (confirm with user first — irreversible):
|
||||
`node scripts/mp-cli.js delete_download_tasks hash=<hash> delete_files=true`
|
||||
`moviepilot tool run delete_download_tasks hash=<hash> delete_files=true`
|
||||
|
||||
### Manage Subscriptions
|
||||
|
||||
List active subscriptions:
|
||||
`node scripts/mp-cli.js query_subscribes status=R`
|
||||
`moviepilot tool run query_subscribes status=R`
|
||||
|
||||
Update subscription filters:
|
||||
`node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p"`
|
||||
`moviepilot tool run update_subscribe subscribe_id=123 resolution="1080p"`
|
||||
|
||||
Only download full-season packs for a TV best-version subscription:
|
||||
`node scripts/mp-cli.js update_subscribe subscribe_id=123 best_version=1 best_version_full=1`
|
||||
`moviepilot tool run update_subscribe subscribe_id=123 best_version=1 best_version_full=1`
|
||||
|
||||
Trigger a search for missing episodes (confirm with user first):
|
||||
`node scripts/mp-cli.js search_subscribe subscribe_id=123`
|
||||
`moviepilot tool run search_subscribe subscribe_id=123`
|
||||
|
||||
Remove a subscription (confirm with user first):
|
||||
`node scripts/mp-cli.js delete_subscribe subscribe_id=123`
|
||||
`moviepilot tool run delete_subscribe subscribe_id=123`
|
||||
|
||||
### Check Library and Subscriptions
|
||||
|
||||
Run before any download or subscription to avoid duplicates.
|
||||
|
||||
Check if the media already exists in the library:
|
||||
`node scripts/mp-cli.js query_library_exists tmdb_id=123456 media_type="movie"`
|
||||
`moviepilot tool run query_library_exists tmdb_id=123456 media_type="movie"`
|
||||
|
||||
Check if the media is already subscribed:
|
||||
`node scripts/mp-cli.js query_subscribes tmdb_id=123456`
|
||||
`moviepilot tool run query_subscribes tmdb_id=123456`
|
||||
|
||||
### Season Validation
|
||||
|
||||
@@ -174,7 +203,7 @@ Mandatory when user specifies a season. Productions sometimes release a show in
|
||||
#### 1. Verify season exists
|
||||
|
||||
Fetch media detail to check available seasons:
|
||||
`node scripts/mp-cli.js query_media_detail tmdb_id=<id> media_type="tv"`
|
||||
`moviepilot tool run query_media_detail tmdb_id=<id> media_type="tv"`
|
||||
|
||||
Compare `season_info` with the user's requested season:
|
||||
1. If the season exists in `season_info` → use that season number directly and return to the calling workflow.
|
||||
@@ -183,11 +212,12 @@ Compare `season_info` with the user's requested season:
|
||||
#### 2. Identify the correct episode range
|
||||
|
||||
Fetch episode schedule for the latest season from `season_info`:
|
||||
`node scripts/mp-cli.js query_episode_schedule tmdb_id=<id> season=<latest_season_number>`
|
||||
`moviepilot tool run query_episode_schedule tmdb_id=<id> season=<latest_season_number>`
|
||||
|
||||
Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. Look for a gap in `air_date` between episodes — the gap indicates a part break, and the episodes after the gap are what the user likely refers to as the next "season". For example, if TMDB Season 1 has episodes 1–24 and there is a multi-month gap between episode 12 and 13, then episodes 13–24 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user.
|
||||
|
||||
## Error handling
|
||||
|
||||
Missing configuration: Ask the user for the backend host and API key. Once provided, save the config persistently — subsequent commands will use it automatically:
|
||||
`node scripts/mp-cli.js -h <HOST> -k <KEY>`
|
||||
Missing configuration or authentication failure: run `moviepilot doctor` to
|
||||
verify the local MoviePilot installation and settings. Do not ask the user to
|
||||
paste the API key into the prompt for local CLI usage.
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const SCRIPT_NAME = process.env.MP_SCRIPT_NAME || path.basename(process.argv[1] || 'mp-cli.js');
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.config', 'moviepilot_cli');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config');
|
||||
|
||||
let commandsJson = [];
|
||||
let commandsLoaded = false;
|
||||
|
||||
let optHost = '';
|
||||
let optKey = '';
|
||||
|
||||
const envHost = process.env.MP_HOST || '';
|
||||
const envKey = process.env.MP_API_KEY || '';
|
||||
|
||||
let mpHost = '';
|
||||
let mpApiKey = '';
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function spacePad(text = '', targetCol = 0) {
|
||||
const spaces = text.length < targetCol ? targetCol - text.length + 2 : 2;
|
||||
return ' '.repeat(spaces);
|
||||
}
|
||||
|
||||
function printBox(title, lines) {
|
||||
const rightPadding = 0;
|
||||
const contentWidth =
|
||||
lines.reduce((max, line) => Math.max(max, line.length), title.length) + rightPadding;
|
||||
const innerWidth = contentWidth + 2;
|
||||
const topLabel = `─ ${title}`;
|
||||
|
||||
console.error(`┌${topLabel}${'─'.repeat(Math.max(innerWidth - topLabel.length, 0))}┐`);
|
||||
for (const line of lines) {
|
||||
console.error(`│ ${line}${' '.repeat(contentWidth - line.length)} │`);
|
||||
}
|
||||
console.error(`└${'─'.repeat(innerWidth)}┘`);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
let cfgHost = '';
|
||||
let cfgKey = '';
|
||||
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
return { cfgHost, cfgKey };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (!line.trim() || /^\s*#/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = line.indexOf('=');
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, index).replace(/\s+/g, '');
|
||||
const value = line.slice(index + 1);
|
||||
|
||||
if (key === 'MP_HOST') {
|
||||
cfgHost = value;
|
||||
} else if (key === 'MP_API_KEY') {
|
||||
cfgKey = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { cfgHost, cfgKey };
|
||||
}
|
||||
|
||||
function saveConfig(host, key) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, `MP_HOST=${host}\nMP_API_KEY=${key}\n`, 'utf8');
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const { cfgHost: initialHost, cfgKey: initialKey } = readConfig();
|
||||
let cfgHost = initialHost;
|
||||
let cfgKey = initialKey;
|
||||
|
||||
if (optHost || optKey) {
|
||||
const nextHost = optHost || cfgHost;
|
||||
const nextKey = optKey || cfgKey;
|
||||
saveConfig(nextHost, nextKey);
|
||||
cfgHost = nextHost;
|
||||
cfgKey = nextKey;
|
||||
}
|
||||
|
||||
mpHost = optHost || mpHost || envHost || cfgHost;
|
||||
mpApiKey = optKey || mpApiKey || envKey || cfgKey;
|
||||
}
|
||||
|
||||
function normalizeType(schema = {}) {
|
||||
if (schema.type) {
|
||||
return schema.type;
|
||||
}
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const candidate = schema.anyOf.find((item) => item && item.type && item.type !== 'null');
|
||||
return candidate?.type || 'string';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function normalizeItemType(schema = {}) {
|
||||
const items = schema.items;
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
if (items.type) {
|
||||
return items.type;
|
||||
}
|
||||
if (Array.isArray(items.anyOf)) {
|
||||
const candidate = items.anyOf.find((item) => item && item.type && item.type !== 'null');
|
||||
return candidate?.type || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCommand(tool = {}) {
|
||||
const properties = tool?.inputSchema?.properties || {};
|
||||
const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : [];
|
||||
const fields = Object.entries(properties)
|
||||
.filter(([fieldName]) => fieldName !== 'explanation')
|
||||
.map(([fieldName, schema]) => ({
|
||||
name: fieldName,
|
||||
type: normalizeType(schema),
|
||||
description: schema?.description || '',
|
||||
required: required.includes(fieldName),
|
||||
item_type: normalizeItemType(schema),
|
||||
}));
|
||||
|
||||
return {
|
||||
name: tool?.name,
|
||||
description: tool?.description || '',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
function request(method, targetUrl, headers = {}, body, timeout = 120000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(targetUrl);
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid URL: ${targetUrl}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(
|
||||
{
|
||||
method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode ? String(res.statusCode) : '',
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy(new Error(`Request timed out after ${timeout}ms`));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body !== undefined) {
|
||||
req.write(body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCommandsJson() {
|
||||
if (commandsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools`, {
|
||||
'X-API-KEY': mpApiKey,
|
||||
});
|
||||
|
||||
if (statusCode !== '200') {
|
||||
console.error(`Error: failed to load command definitions (HTTP ${statusCode || 'unknown'})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
} catch {
|
||||
fail('Error: backend returned invalid JSON for command definitions');
|
||||
}
|
||||
|
||||
commandsJson = Array.isArray(response)
|
||||
? response.map((tool) => normalizeCommand(tool))
|
||||
: [];
|
||||
|
||||
commandsLoaded = true;
|
||||
}
|
||||
|
||||
async function loadCommandJson(commandName) {
|
||||
const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools/${commandName}`, {
|
||||
'X-API-KEY': mpApiKey,
|
||||
});
|
||||
|
||||
if (statusCode === '404') {
|
||||
console.error(`Error: command '${commandName}' not found`);
|
||||
console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (statusCode !== '200') {
|
||||
console.error(`Error: failed to load command definition (HTTP ${statusCode || 'unknown'})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
} catch {
|
||||
fail(`Error: backend returned invalid JSON for command '${commandName}'`);
|
||||
}
|
||||
|
||||
return normalizeCommand(response);
|
||||
}
|
||||
|
||||
function ensureConfig() {
|
||||
loadConfig();
|
||||
let ok = true;
|
||||
|
||||
if (!mpHost) {
|
||||
console.error('Error: backend host is not configured.');
|
||||
console.error(' Use: -h HOST to set it');
|
||||
console.error(' Or set environment variable: MP_HOST=http://localhost:3001');
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!mpApiKey) {
|
||||
console.error('Error: API key is not configured.');
|
||||
console.error(' Use: -k KEY to set it');
|
||||
console.error(' Or set environment variable: MP_API_KEY=your_key');
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
process.stdout.write(`${value}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function formatUsageValue(field) {
|
||||
if (field?.type === 'array') {
|
||||
return "'<value1>,<value2>'";
|
||||
}
|
||||
return '<value>';
|
||||
}
|
||||
|
||||
async function cmdList() {
|
||||
await loadCommandsJson();
|
||||
const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const command of sortedCommands) {
|
||||
process.stdout.write(`${command.name}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdShow(commandName) {
|
||||
if (!commandName) {
|
||||
fail(`Usage: ${SCRIPT_NAME} show <command>`);
|
||||
}
|
||||
|
||||
const command = await loadCommandJson(commandName);
|
||||
|
||||
const commandLabel = 'Command:';
|
||||
const descriptionLabel = 'Description:';
|
||||
const paramsLabel = 'Parameters:';
|
||||
const usageLabel = 'Usage:';
|
||||
const detailLabelWidth = Math.max(
|
||||
commandLabel.length,
|
||||
descriptionLabel.length,
|
||||
paramsLabel.length,
|
||||
usageLabel.length
|
||||
);
|
||||
|
||||
process.stdout.write(`${commandLabel} ${command.name}\n`);
|
||||
process.stdout.write(`${descriptionLabel} ${command.description || '(none)'}\n\n`);
|
||||
|
||||
if (command.fields.length === 0) {
|
||||
process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`);
|
||||
} else {
|
||||
const fieldLines = command.fields.map((field) => [
|
||||
field.required ? `${field.name}*` : field.name,
|
||||
field.type,
|
||||
field.description,
|
||||
]);
|
||||
|
||||
const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);
|
||||
const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0);
|
||||
|
||||
process.stdout.write(`${paramsLabel}\n`);
|
||||
for (const [fieldName, fieldType, fieldDesc] of fieldLines) {
|
||||
process.stdout.write(
|
||||
` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const usageLine = `${command.name}`;
|
||||
const reqPart = command.fields
|
||||
.filter((field) => field.required)
|
||||
.map((field) => ` ${field.name}=${formatUsageValue(field)}`)
|
||||
.join('');
|
||||
const optPart = command.fields
|
||||
.filter((field) => !field.required)
|
||||
.map((field) => ` [${field.name}=${formatUsageValue(field)}]`)
|
||||
.join('');
|
||||
|
||||
process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`);
|
||||
}
|
||||
|
||||
function buildArguments(pairs) {
|
||||
const args = { explanation: 'CLI invocation' };
|
||||
|
||||
for (const kv of pairs) {
|
||||
if (!kv.includes('=')) {
|
||||
fail(`Error: argument must be in key=value format, got: '${kv}'`);
|
||||
}
|
||||
|
||||
const index = kv.indexOf('=');
|
||||
args[kv.slice(0, index)] = kv.slice(index + 1);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async function cmdRun(commandName, pairs) {
|
||||
if (!commandName) {
|
||||
fail(`Usage: ${SCRIPT_NAME} <command> [key=value ...]`);
|
||||
}
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
tool_name: commandName,
|
||||
arguments: buildArguments(pairs),
|
||||
});
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
'POST',
|
||||
`${mpHost}/api/v1/mcp/tools/call`,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'X-API-KEY': mpApiKey,
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
|
||||
if (statusCode && statusCode !== '200' && statusCode !== '201') {
|
||||
console.error(`Warning: HTTP status ${statusCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'error') && parsed.error) {
|
||||
printValue(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'result')) {
|
||||
if (typeof parsed.result === 'string') {
|
||||
try {
|
||||
printValue(JSON.parse(parsed.result));
|
||||
} catch {
|
||||
printValue(parsed.result);
|
||||
}
|
||||
} else {
|
||||
printValue(parsed.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
printValue(parsed);
|
||||
} catch {
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
const { cfgHost, cfgKey } = readConfig();
|
||||
let effectiveHost = mpHost || envHost || cfgHost;
|
||||
let effectiveKey = mpApiKey || envKey || cfgKey;
|
||||
|
||||
if (optHost) {
|
||||
effectiveHost = optHost;
|
||||
}
|
||||
if (optKey) {
|
||||
effectiveKey = optKey;
|
||||
}
|
||||
|
||||
if (!effectiveHost || !effectiveKey) {
|
||||
const warningLines = [];
|
||||
if (!effectiveHost) {
|
||||
const opt = '-h HOST';
|
||||
const desc = 'set backend host';
|
||||
warningLines.push(`${opt}${spacePad(opt)}${desc}`);
|
||||
}
|
||||
if (!effectiveKey) {
|
||||
const opt = '-k KEY';
|
||||
const desc = 'set API key';
|
||||
warningLines.push(`${opt}${spacePad(opt)}${desc}`);
|
||||
}
|
||||
printBox('Warning: not configured', warningLines);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
process.stdout.write(`Usage: ${SCRIPT_NAME} [-h HOST] [-k KEY] [COMMAND] [ARGS...]\n\n`);
|
||||
const optionWidth = Math.max('-h HOST'.length, '-k KEY'.length);
|
||||
process.stdout.write('Options:\n');
|
||||
process.stdout.write(` -h HOST${spacePad('-h HOST', optionWidth)}backend host\n`);
|
||||
process.stdout.write(` -k KEY${spacePad('-k KEY', optionWidth)}API key\n\n`);
|
||||
const commandWidth = Math.max(
|
||||
'(no command)'.length,
|
||||
'list'.length,
|
||||
'show <command>'.length,
|
||||
'<command> [k=v...]'.length
|
||||
);
|
||||
process.stdout.write('Commands:\n');
|
||||
process.stdout.write(
|
||||
` (no command)${spacePad('(no command)', commandWidth)}save config when -h and -k are provided\n`
|
||||
);
|
||||
process.stdout.write(` list${spacePad('list', commandWidth)}list all commands\n`);
|
||||
process.stdout.write(
|
||||
` show <command>${spacePad('show <command>', commandWidth)}show command details and usage example\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
` <command> [k=v...]${spacePad('<command> [k=v...]', commandWidth)}run a command\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = [];
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === '--help' || arg === '-?') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === '-h') {
|
||||
index += 1;
|
||||
optHost = argv[index] || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '-k') {
|
||||
index += 1;
|
||||
optKey = argv[index] || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--') {
|
||||
args.push(...argv.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
args.push(arg);
|
||||
}
|
||||
|
||||
if ((optHost && !optKey) || (!optHost && optKey)) {
|
||||
fail('Error: -h and -k must be provided together');
|
||||
}
|
||||
|
||||
const command = args[0] || '';
|
||||
|
||||
if (command === 'list') {
|
||||
ensureConfig();
|
||||
await cmdList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'show') {
|
||||
ensureConfig();
|
||||
await cmdShow(args[1] || '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
if (optHost || optKey) {
|
||||
loadConfig();
|
||||
process.stdout.write('Configuration saved.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureConfig();
|
||||
await cmdRun(command, args.slice(1));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(`Error: ${error.message}`);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-update
|
||||
version: 2
|
||||
version: 3
|
||||
description: Use this skill when you need to check MoviePilot versions, restart MoviePilot, or trigger a MoviePilot upgrade. Prefer the built-in system APIs instead of docker commands or manual file replacement. If auto-update on restart is already enabled, just restart. If it is disabled, call the upgrade API so MoviePilot performs a one-shot upgrade and restart.
|
||||
---
|
||||
|
||||
@@ -12,13 +12,7 @@ Use this skill for MoviePilot restart and upgrade operations.
|
||||
|
||||
## Setup
|
||||
|
||||
This skill reuses the `moviepilot-api` client configuration.
|
||||
|
||||
Configure host and API key once:
|
||||
|
||||
```bash
|
||||
python ../moviepilot-api/scripts/mp-api.py configure --host http://localhost:3000 --apikey <API_TOKEN>
|
||||
```
|
||||
This skill reuses the `moviepilot-api` client. When running inside the MoviePilot project, the API client imports `app.core.config.settings` and reads the local host, port, and API token directly. Do not ask the user for `API_TOKEN`.
|
||||
|
||||
## Preferred Commands
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ API_SCRIPT = SCRIPT_DIR.parents[1] / "moviepilot-api" / "scripts" / "mp-api.py"
|
||||
|
||||
|
||||
def run_api_call(args: list[str]) -> int:
|
||||
"""调用 MoviePilot REST API 客户端执行更新相关接口。"""
|
||||
command = [sys.executable, str(API_SCRIPT), *args]
|
||||
return_code = __import__("subprocess").run(command, check=False).returncode
|
||||
return return_code
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
"""输出更新脚本的命令行用法。"""
|
||||
print(
|
||||
"Usage:\n"
|
||||
f" python {Path(sys.argv[0]).name} versions\n"
|
||||
@@ -27,6 +29,7 @@ def print_usage() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行 MoviePilot 更新脚本入口。"""
|
||||
argv = sys.argv[1:]
|
||||
if not argv or argv[0] in {"-h", "--help", "help"}:
|
||||
print_usage()
|
||||
|
||||
22
tests/test_agent_prompt_secrets.py
Normal file
22
tests/test_agent_prompt_secrets.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.agent.prompt import PromptManager
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_moviepilot_info_does_not_expose_api_token_or_database_password(monkeypatch) -> None:
|
||||
"""系统提示词中的运行信息不能暴露 API 令牌或数据库密码。"""
|
||||
monkeypatch.setattr(settings, "API_TOKEN", "prompt-secret-token")
|
||||
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
|
||||
monkeypatch.setattr(settings, "DB_POSTGRESQL_HOST", "db.example.local")
|
||||
monkeypatch.setattr(settings, "DB_POSTGRESQL_PORT", "5432")
|
||||
monkeypatch.setattr(settings, "DB_POSTGRESQL_DATABASE", "moviepilot")
|
||||
monkeypatch.setattr(settings, "DB_POSTGRESQL_USERNAME", "moviepilot_user")
|
||||
monkeypatch.setattr(settings, "DB_POSTGRESQL_PASSWORD", "prompt-db-password")
|
||||
|
||||
manager = PromptManager()
|
||||
moviepilot_info = manager._get_moviepilot_info()
|
||||
|
||||
assert "prompt-secret-token" not in moviepilot_info
|
||||
assert "prompt-db-password" not in moviepilot_info
|
||||
assert "moviepilot_user:prompt-db-password" not in moviepilot_info
|
||||
assert "API认证: 由内部工具自动处理" in moviepilot_info
|
||||
assert "凭据由内部工具读取" in moviepilot_info
|
||||
65
tests/test_builtin_skill_boundaries.py
Normal file
65
tests/test_builtin_skill_boundaries.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS_ROOT = PROJECT_ROOT / "skills"
|
||||
|
||||
|
||||
def _read_skill(skill_name: str) -> str:
|
||||
"""读取内置技能的 SKILL.md 内容。"""
|
||||
return (SKILLS_ROOT / skill_name / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _frontmatter_value(content: str, key: str) -> str:
|
||||
"""从 SKILL.md frontmatter 中读取单行字段值。"""
|
||||
for line in content.splitlines():
|
||||
if line.startswith(f"{key}:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||
expected_versions = {
|
||||
"database-operation": "3",
|
||||
"moviepilot-api": "2",
|
||||
"moviepilot-cli": "3",
|
||||
"moviepilot-update": "3",
|
||||
}
|
||||
|
||||
for skill_name, expected_version in expected_versions.items():
|
||||
content = _read_skill(skill_name)
|
||||
|
||||
assert _frontmatter_value(content, "version") == expected_version
|
||||
|
||||
|
||||
def test_moviepilot_cli_skill_uses_local_tool_boundary() -> None:
|
||||
"""CLI 技能应只描述本地 MCP tool 边界,不再默认使用旧 Node 脚本。"""
|
||||
content = _read_skill("moviepilot-cli")
|
||||
|
||||
assert "moviepilot tool" in content
|
||||
assert "scripts/mp-cli.js" not in content
|
||||
assert "Use `scripts/mp-cli.js`" not in content
|
||||
assert "node scripts/mp-cli.js" not in content
|
||||
assert "any request involving movies" not in content
|
||||
assert "whenever the user explicitly mentions MoviePilot" not in content
|
||||
assert "Do not ask the user" in content
|
||||
assert "moviepilot-api" in content
|
||||
assert "database-operation" in content
|
||||
|
||||
|
||||
def test_api_and_database_skills_declare_fallback_boundaries() -> None:
|
||||
"""API 和数据库技能应明确各自兜底边界,避免抢占普通产品操作。"""
|
||||
api_content = _read_skill("moviepilot-api")
|
||||
db_content = _read_skill("database-operation")
|
||||
|
||||
assert "REST API bridge" in api_content
|
||||
assert "Do not use this skill just because MoviePilot is mentioned" in api_content
|
||||
assert "moviepilot-cli" in api_content
|
||||
assert "Direct SQL query or database update" in api_content
|
||||
|
||||
assert "direct SQL boundary" in db_content
|
||||
assert "Use this skill as the final fallback" in db_content
|
||||
assert "INSERT" in db_content
|
||||
assert "UPDATE" in db_content
|
||||
assert "DELETE" in db_content
|
||||
143
tests/test_skill_scripts_security.py
Normal file
143
tests/test_skill_scripts_security.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MP_API_SCRIPT = PROJECT_ROOT / "skills" / "moviepilot-api" / "scripts" / "mp-api.py"
|
||||
MP_DB_SCRIPT = PROJECT_ROOT / "skills" / "database-operation" / "scripts" / "mp-db.py"
|
||||
|
||||
|
||||
def _load_script(path: Path, module_name: str) -> ModuleType:
|
||||
"""按文件路径加载 skill 脚本模块。"""
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_mp_api_uses_settings_without_prompt_token(monkeypatch, tmp_path) -> None:
|
||||
"""API 脚本应直接读取 settings,而不是要求提示词提供 token。"""
|
||||
module = _load_script(MP_API_SCRIPT, "mp_api_script")
|
||||
runtime_dir = tmp_path / "temp"
|
||||
runtime_dir.mkdir()
|
||||
|
||||
class FakeSettings:
|
||||
"""提供 API 脚本本地配置所需字段。"""
|
||||
|
||||
TEMP_PATH = runtime_dir
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 3001
|
||||
API_TOKEN = "settings-token"
|
||||
|
||||
monkeypatch.setattr(module, "_ensure_project_import", lambda: None)
|
||||
monkeypatch.setattr(module, "read_config", lambda: ("http://file-host", "file-token"))
|
||||
monkeypatch.setattr(
|
||||
"app.core.config.settings",
|
||||
FakeSettings,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.delenv("MP_HOST", raising=False)
|
||||
monkeypatch.delenv("MP_API_KEY", raising=False)
|
||||
|
||||
host, key = module.resolve_config()
|
||||
|
||||
assert host == "http://127.0.0.1:3001"
|
||||
assert key == "settings-token"
|
||||
|
||||
|
||||
def test_mp_db_rejects_write_statement_without_write_flag() -> None:
|
||||
"""数据库脚本默认必须拒绝写操作。"""
|
||||
module = _load_script(MP_DB_SCRIPT, "mp_db_script")
|
||||
|
||||
assert module._is_supported_statement("SELECT COUNT(*) FROM downloadhistory", False)
|
||||
assert not module._is_supported_statement("UPDATE subscribe SET state='S' WHERE id=1", False)
|
||||
assert not module._is_supported_statement(
|
||||
"SELECT COUNT(*) FROM downloadhistory; DELETE FROM downloadhistory WHERE id=1",
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_mp_db_returns_sensitive_columns_without_masking(monkeypatch, capsys) -> None:
|
||||
"""数据库脚本应原样返回敏感字段供 Agent 内部使用。"""
|
||||
module = _load_script(MP_DB_SCRIPT, "mp_db_script_unmasked")
|
||||
|
||||
class FakeRow:
|
||||
"""模拟 SQLAlchemy 查询行。"""
|
||||
|
||||
_mapping = {"token": "raw-token", "password": "raw-password"}
|
||||
|
||||
class FakeResult:
|
||||
"""模拟返回查询结果的 SQLAlchemy Result。"""
|
||||
|
||||
returns_rows = True
|
||||
|
||||
def fetchall(self) -> list[FakeRow]:
|
||||
"""返回测试查询行。"""
|
||||
return [FakeRow()]
|
||||
|
||||
class FakeConnection:
|
||||
"""模拟数据库连接。"""
|
||||
|
||||
def execute(self, statement: Any) -> FakeResult:
|
||||
"""返回测试结果。"""
|
||||
return FakeResult()
|
||||
|
||||
class FakeTransaction:
|
||||
"""模拟 engine.begin() 上下文。"""
|
||||
|
||||
def __enter__(self) -> FakeConnection:
|
||||
"""进入上下文时返回连接。"""
|
||||
return FakeConnection()
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> bool:
|
||||
"""退出上下文。"""
|
||||
return False
|
||||
|
||||
class FakeEngine:
|
||||
"""模拟数据库引擎。"""
|
||||
|
||||
def begin(self) -> FakeTransaction:
|
||||
"""返回事务上下文。"""
|
||||
return FakeTransaction()
|
||||
|
||||
monkeypatch.setattr(module, "_build_engine", lambda: FakeEngine())
|
||||
|
||||
assert module.run_query("SELECT token, password FROM site LIMIT 1") == 0
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["rows"] == [{"token": "raw-token", "password": "raw-password"}]
|
||||
|
||||
|
||||
def test_mp_db_write_command_allows_write_statement(monkeypatch) -> None:
|
||||
"""数据库脚本 write 子命令应直接允许写操作。"""
|
||||
module = _load_script(MP_DB_SCRIPT, "mp_db_script_write")
|
||||
calls = []
|
||||
|
||||
def fake_run_query(sql: str, *, limit: int = 100, allow_write: bool = False) -> int:
|
||||
"""记录 write 子命令传入的执行参数。"""
|
||||
calls.append({"sql": sql, "limit": limit, "allow_write": allow_write})
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(module, "run_query", fake_run_query)
|
||||
monkeypatch.setattr(
|
||||
module.sys,
|
||||
"argv",
|
||||
[
|
||||
"mp-db.py",
|
||||
"write",
|
||||
"UPDATE subscribe SET state = 'S' WHERE id = 123",
|
||||
],
|
||||
)
|
||||
|
||||
assert module.main() == 0
|
||||
assert calls == [
|
||||
{
|
||||
"sql": "UPDATE subscribe SET state = 'S' WHERE id = 123",
|
||||
"limit": 0,
|
||||
"allow_write": True,
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user