mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-19 19:46:55 +08:00
feat: 优化工具和SKILL.md
This commit is contained in:
@@ -1,79 +1,119 @@
|
||||
---
|
||||
name: moviepilot-cli
|
||||
description: Use this skill when the user wants to manage a home media ecosystem via MoviePilot. Covers searching movies/TV shows/anime, managing subscriptions, controlling downloads (torrent search, quality filtering), monitoring download progress, and organizing media libraries. Trigger when user mentions movie/show titles, asks about subscriptions, downloads, library organization, or references MoviePilot directly.
|
||||
description: Use this skill when the user wants to find, download, or subscribe to a movie or TV show (including anime); asks about download or subscription status; needs to check or organize the media library; or mentions MoviePilot directly. Covers the full media acquisition workflow via MoviePilot — searching TMDB, filtering and downloading torrents from PT indexer sites, managing subscriptions for automatic episode tracking, and handling library organization, site accounts, filter rules, and schedulers.
|
||||
---
|
||||
|
||||
# MoviePilot Media Management Skill
|
||||
# MoviePilot CLI
|
||||
|
||||
## Overview
|
||||
Use `scripts/mp-cli.js` to interact with the MoviePilot backend.
|
||||
|
||||
This skill interacts with the MoviePilot backend via the Node.js command-line script `scripts/mp-cli.js`. It supports four core capabilities: media search and recognition, subscription management, download control, and media library organization.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
Usage: mp-cli.js [-h HOST] [-k KEY] [COMMAND] [ARGS...]
|
||||
|
||||
Options:
|
||||
-h HOST backend host
|
||||
-k KEY API key
|
||||
|
||||
Commands:
|
||||
(no command) save config when -h and -k are provided
|
||||
list list all commands
|
||||
show <command> show command details and usage example
|
||||
<command> [k=v...] run a command
|
||||
```
|
||||
|
||||
## Discovering Available Tools
|
||||
|
||||
Before performing any task, use these two commands to understand what the current environment supports.
|
||||
|
||||
**List all available commands:**
|
||||
## Discover Commands
|
||||
|
||||
```bash
|
||||
node scripts/mp-cli.js list
|
||||
node scripts/mp-cli.js list # list all available commands
|
||||
node scripts/mp-cli.js show <command> # show parameters, required fields, and usage
|
||||
```
|
||||
|
||||
**Inspect a command's parameters:**
|
||||
Always run `show <command>` before calling a command. Do not guess parameter names or argument formats.
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Category | Commands |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Media Search | search_media, recognize_media, query_media_detail, get_recommendations, search_person, search_person_credits |
|
||||
| Torrent | search_torrents, get_search_results |
|
||||
| Download | add_download, query_download_tasks, delete_download, query_downloaders |
|
||||
| Subscription | add_subscribe, query_subscribes, update_subscribe, delete_subscribe, search_subscribe, query_subscribe_history, query_popular_subscribes, query_subscribe_shares |
|
||||
| Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history |
|
||||
| Files | list_directory, query_directory_settings |
|
||||
| Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie |
|
||||
| System | query_schedulers, run_scheduler, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Don't guess command parameters.** Parameter names vary per command and are not inferrable. Always run `show <command>` first.
|
||||
- **`search_torrents` results are cached server-side.** `get_search_results` reads from that cache — always run `search_torrents` first in the same session before filtering.
|
||||
- **Omitting `sites` uses the user's configured default sites**, not all available sites. Only call `query_sites` and pass `sites=` when the user explicitly asks for a specific site.
|
||||
- **TMDB season numbers don't always match fan-labeled seasons.** Anime and long-running shows often split one TMDB season into parts. Always validate with `query_media_detail` when the user mentions a specific season.
|
||||
- **`add_download` is irreversible without manual cleanup.** Always present torrent details and wait for explicit confirmation before calling it.
|
||||
- **`volume_factor` and `freedate_diff` indicate promotional status.** `volume_factor` describes the discount type (e.g. `免费` = free download, `2X` = double upload only, `2X免费` = free download + double upload, `普通` = no discount). `freedate_diff` is the remaining free window (e.g. `2天3小时`); empty means no active promotion. Always include both fields when presenting results — they are critical for the user to pick the best-value torrent.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Search and Download
|
||||
|
||||
```bash
|
||||
node scripts/mp-cli.js show <command>
|
||||
# 1. Search TMDB to get tmdb_id
|
||||
node scripts/mp-cli.js search_media title="流浪地球2" media_type="movie"
|
||||
|
||||
# [TV only, only if user specified a season] Validate season — see "Season Validation" section below
|
||||
node scripts/mp-cli.js query_media_detail tmdb_id=... media_type="tv"
|
||||
|
||||
# 2. Search torrents using tmdb_id — results are cached server-side
|
||||
# Response includes available filter options (resolution, release group, etc.)
|
||||
# [Optional] If the user specifies sites, first run query_sites to get IDs, then pass them via sites param
|
||||
node scripts/mp-cli.js query_sites # get site IDs
|
||||
node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" # use user's default sites
|
||||
node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" sites='1,3' # override with specific sites
|
||||
|
||||
# 3. Present available filter options to the user and ask for their preferences
|
||||
# e.g. "Available resolutions: 1080p, 2160p. Release groups: CMCT, PTer. Which do you prefer?"
|
||||
|
||||
# 4. Filter cached results using the user's selected preferences
|
||||
node scripts/mp-cli.js get_search_results resolution='2160p'
|
||||
|
||||
# 5. Present ALL filtered results as a numbered list — do not pre-select or discard any
|
||||
# Show for each: index, title, size, seeders, resolution, release group, volume_factor, freedate_diff
|
||||
# Let the user pick by number; only then call add_download
|
||||
node scripts/mp-cli.js add_download torrent_url="..."
|
||||
```
|
||||
|
||||
`show` displays a command's name, its parameters, and a usage example. For each parameter, it shows the name, type, required/optional status, and description. **Always run `show` before calling any command** — never guess parameter names or formats.
|
||||
### Add Subscription
|
||||
|
||||
## Standard Workflow
|
||||
```bash
|
||||
# 1. Search to get tmdb_id (required for accurate identification)
|
||||
node scripts/mp-cli.js search_media title="黑镜" media_type="tv"
|
||||
|
||||
Follow this sequence for any media task:
|
||||
|
||||
```
|
||||
1. list → confirm which commands are available
|
||||
2. show <command> → confirm parameter format before calling
|
||||
3. Search / recognize → resolve exact metadata (TMDB ID, season, episode)
|
||||
4. Check library / subs → avoid duplicate downloads or subscriptions
|
||||
5. Execute action → downloads require explicit user confirmation first
|
||||
6. Confirm final state → report the outcome to the user
|
||||
# 2. Subscribe — the system will auto-download new episodes
|
||||
node scripts/mp-cli.js add_subscribe title="黑镜" year="2011" media_type="tv" tmdb_id=42009
|
||||
```
|
||||
|
||||
## Tool Calling Strategy
|
||||
### Manage Subscriptions
|
||||
|
||||
**Fallback search**: If a media search returns no results, try in order: fuzzy recognition → web search → ask the user for more information.
|
||||
```bash
|
||||
node scripts/mp-cli.js query_subscribes status=R # list active
|
||||
node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p" # update filters
|
||||
node scripts/mp-cli.js search_subscribe subscribe_id=123 # search missing episodes
|
||||
node scripts/mp-cli.js delete_subscribe subscribe_id=123 # remove
|
||||
```
|
||||
|
||||
**Disambiguation**: If search results are ambiguous, call the detail-query command to obtain precise metadata before proceeding.
|
||||
## Season Validation (only when user specifies a season)
|
||||
|
||||
## Download Safety Rules
|
||||
Skip this section if the user did not mention a specific season.
|
||||
|
||||
Before executing any download command, you **must**:
|
||||
**Step 1 — Verify the season exists:**
|
||||
|
||||
1. Search for and retrieve a list of available torrent resources.
|
||||
2. Present torrent details to the user (size, seeders, quality, release group).
|
||||
3. **Wait for explicit user confirmation** before initiating the download.
|
||||
```bash
|
||||
node scripts/mp-cli.js query_media_detail tmdb_id=<id> media_type="tv"
|
||||
```
|
||||
|
||||
Check `season_info` against the season the user requested:
|
||||
|
||||
- **Season exists:** use that season number directly, then proceed to torrent search.
|
||||
- **Season does not exist:** anime and long-running shows often split one TMDB season into multiple parts that fans call separate seasons. Use the latest available season number and continue to Step 2.
|
||||
|
||||
**Step 2 — Identify the correct episode range:**
|
||||
|
||||
```bash
|
||||
node scripts/mp-cli.js query_episode_schedule tmdb_id=<id> season=<latest_season>
|
||||
```
|
||||
|
||||
Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. If no such block exists, tell the user the content is unavailable. Otherwise, confirm the episode range with the user before proceeding to torrent search.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Resolution |
|
||||
| --------------------- | --------------------------------------------------------------------------- |
|
||||
| No search results | Try fuzzy recognition → web search → ask the user |
|
||||
| Download failure | Check downloader status; advise user to verify disk space |
|
||||
| Missing configuration | Prompt user to run `node scripts/mp-cli.js -h <HOST> -k <KEY>` to configure |
|
||||
| Error | Resolution |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No search results | Retry with an alternative title (e.g. English title). If still empty, ask the user to confirm the title or provide the TMDB ID directly. |
|
||||
| Download failure | Check downloader status with `query_downloaders`; advise the user to verify storage or downloader health. If these are normal, mention it could be a network error and suggest retrying later. |
|
||||
| Missing configuration | Ask the user for the backend host and API key. Once provided, run `node scripts/mp-cli.js -h <HOST> -k <KEY>` (no command) to save the config persistently — subsequent commands will use it automatically. |
|
||||
|
||||
@@ -129,6 +129,26 @@ function normalizeItemType(schema = {}) {
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url;
|
||||
@@ -192,31 +212,38 @@ async function loadCommandsJson() {
|
||||
}
|
||||
|
||||
commandsJson = Array.isArray(response)
|
||||
? response
|
||||
.map((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,
|
||||
};
|
||||
})
|
||||
? 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 '${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;
|
||||
@@ -246,69 +273,76 @@ function printValue(value) {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function formatUsageValue(field) {
|
||||
if (field?.type === 'array') {
|
||||
return "'<value1>,<value2>'";
|
||||
}
|
||||
return '<value>';
|
||||
}
|
||||
|
||||
async function cmdList() {
|
||||
await loadCommandsJson();
|
||||
for (const command of commandsJson) {
|
||||
process.stdout.write(`- ${command.name}${spacePad(command.name)}${command.description}\n`);
|
||||
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) {
|
||||
await loadCommandsJson();
|
||||
|
||||
if (!commandName) {
|
||||
fail(`Usage: ${SCRIPT_NAME} show <command>`);
|
||||
}
|
||||
|
||||
const command = commandsJson.find((item) => item.name === commandName);
|
||||
if (!command) {
|
||||
console.error(`Error: command '${commandName}' not found`);
|
||||
console.error(`Run '${SCRIPT_NAME} list' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
const command = await loadCommandJson(commandName);
|
||||
|
||||
const commandLabel = 'Command:';
|
||||
const descriptionLabel = 'Description:';
|
||||
const paramsLabel = 'Parameters:';
|
||||
const usageLabel = 'Usage example:';
|
||||
const detailLabelWidth = Math.max(commandLabel.length, paramsLabel.length, usageLabel.length);
|
||||
const usageLabel = 'Usage:';
|
||||
const detailLabelWidth = Math.max(
|
||||
commandLabel.length,
|
||||
descriptionLabel.length,
|
||||
paramsLabel.length,
|
||||
usageLabel.length
|
||||
);
|
||||
|
||||
process.stdout.write(`${commandLabel} ${command.name}\n\n`);
|
||||
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.name,
|
||||
field.required ? `${field.name}*` : field.name,
|
||||
field.type,
|
||||
field.required ? '[required]' : '[optional]',
|
||||
field.description,
|
||||
]);
|
||||
|
||||
const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);
|
||||
const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0);
|
||||
const reqWidth = Math.max(...fieldLines.map(([, , required]) => required.length), 0);
|
||||
|
||||
process.stdout.write(`${paramsLabel}\n`);
|
||||
for (const [fieldName, fieldType, fieldRequired, fieldDesc] of fieldLines) {
|
||||
for (const [fieldName, fieldType, fieldDesc] of fieldLines) {
|
||||
process.stdout.write(
|
||||
` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldRequired}${spacePad(fieldRequired, reqWidth)}${fieldDesc}\n`
|
||||
` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const usageLine = `${SCRIPT_NAME} ${command.name}`;
|
||||
const reqPart = command.fields.filter((field) => field.required).map((field) => ` ${field.name}=<value>`).join('');
|
||||
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}=<value>]`)
|
||||
.map((field) => ` [${field.name}=${formatUsageValue(field)}]`)
|
||||
.join('');
|
||||
|
||||
process.stdout.write(
|
||||
`\n${usageLabel}${spacePad(usageLabel, detailLabelWidth)}${usageLine}${reqPart}${optPart}\n`
|
||||
);
|
||||
process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`);
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
@@ -420,7 +454,7 @@ async function cmdRun(commandName, pairs) {
|
||||
const command = commandsJson.find((item) => item.name === commandName);
|
||||
if (!command) {
|
||||
console.error(`Error: command '${commandName}' not found`);
|
||||
console.error(`Run '${SCRIPT_NAME} list' to see available commands`);
|
||||
console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -446,12 +480,17 @@ async function cmdRun(commandName, pairs) {
|
||||
|
||||
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);
|
||||
printValue({ result: parsed.result });
|
||||
}
|
||||
} else {
|
||||
printValue(parsed.result);
|
||||
|
||||
Reference in New Issue
Block a user