mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
refactor(agent): unify tools behind API gateway and provider skills
This commit is contained in:
+19
-18
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: browser-use
|
||||
version: 1
|
||||
version: 2
|
||||
description: >-
|
||||
Use this skill when the user asks the agent to open, browse, inspect, extract
|
||||
content from, click through, fill forms on, screenshot, or verify a web page
|
||||
@@ -8,7 +8,8 @@ description: >-
|
||||
interaction, such as checking a site page, confirming a JavaScript-rendered
|
||||
result, testing login state, capturing visible errors, or updating and
|
||||
validating tracker site cookies.
|
||||
allowed-tools: browse_webpage recognize_captcha search_web query_sites update_site_cookie test_site update_site
|
||||
allowed-tools: browse_webpage recognize_captcha search_web moviepilot_api
|
||||
allowed-api-operations: site.list site.cookie.update site.test site.update
|
||||
---
|
||||
|
||||
# Browser Use
|
||||
@@ -48,13 +49,13 @@ dedicated tool can complete the task more directly and safely.
|
||||
target URL. It supports DDGS-backed `search_engine` (`auto`, `duckduckgo`,
|
||||
`google`, `brave`, etc.) and `site_url` for limiting results to a specified
|
||||
domain or URL path. It uses the configured system proxy by default.
|
||||
- `query_sites` - Get MoviePilot site IDs before site-specific operations.
|
||||
- `site.list` through `moviepilot_api` - Get site IDs before site-specific operations.
|
||||
Non-admin callers receive a safe view without Cookie, RSS, Token, or API Key
|
||||
fields.
|
||||
- `update_site_cookie` - Update a configured site's Cookie and User-Agent using
|
||||
- `site.cookie.update` - Update a configured site's Cookie and User-Agent using
|
||||
username, password, and optional two-step code.
|
||||
- `test_site` - Verify configured site connectivity and login status.
|
||||
- `update_site` - Update existing site settings when the user explicitly asks.
|
||||
- `site.test` - Verify configured site connectivity and login status.
|
||||
- `site.update` - Update existing site settings when the user explicitly asks.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
@@ -68,7 +69,7 @@ Examples:
|
||||
|
||||
- Query downloads, subscriptions, media, sites, or library state with the
|
||||
existing MoviePilot skills/tools.
|
||||
- Use `query_sites`, `update_site_cookie`, and `test_site` for configured
|
||||
- Use `site.list`, `site.cookie.update`, and `site.test` through `moviepilot_api` for configured
|
||||
tracker sites before manually browsing their pages.
|
||||
|
||||
### 2. Find Or Open The Target
|
||||
@@ -149,7 +150,7 @@ Before finalizing, verify the outcome with one of:
|
||||
|
||||
- `get_content` for text or data changes.
|
||||
- `screenshot` for visual state.
|
||||
- `test_site` for MoviePilot configured tracker connectivity.
|
||||
- `site.test` through `moviepilot_api` for MoviePilot configured tracker connectivity.
|
||||
|
||||
Report the result with the final URL, observed status, and any remaining
|
||||
uncertainty. If the page failed, include the visible error text and the action
|
||||
@@ -159,11 +160,11 @@ that failed.
|
||||
|
||||
### Diagnose A Configured Site
|
||||
|
||||
1. Use `query_sites` to find the site ID.
|
||||
2. Use `test_site` with the site ID.
|
||||
1. Use `site.list` to find the site ID.
|
||||
2. Use `site.test` with path parameter `site_id`.
|
||||
3. If the site fails and the user provided credentials, use
|
||||
`update_site_cookie`.
|
||||
4. Run `test_site` again to confirm.
|
||||
`site.cookie.update`.
|
||||
4. Run `site.test` again to confirm.
|
||||
5. Use `browse_webpage` only if the failure message is unclear or the user asks
|
||||
to inspect the visible page.
|
||||
|
||||
@@ -173,7 +174,7 @@ Use the dedicated cookie tool instead of manually logging in through the
|
||||
browser:
|
||||
|
||||
```text
|
||||
update_site_cookie site_identifier=<id> username="..." password="..." two_step_code="..."
|
||||
moviepilot_api operation_id=site.cookie.update path_params.site_id=<id> body.username="..." body.password="..." body.two_step_code="..."
|
||||
```
|
||||
|
||||
Ask for missing username, password, or two-step code only when required for the
|
||||
@@ -236,16 +237,16 @@ User: `打开这个网页看看报什么错`
|
||||
|
||||
User: `帮我看看某个站点是不是登录失效了`
|
||||
|
||||
1. `query_sites`
|
||||
2. `test_site site_identifier=<id>`
|
||||
1. `moviepilot_api` with `operation_id=site.list`
|
||||
2. `moviepilot_api` with `operation_id=site.test` and `path_params.site_id=<id>`
|
||||
3. If needed, ask whether to update Cookie.
|
||||
|
||||
User: `帮我更新某站 Cookie`
|
||||
|
||||
1. `query_sites`
|
||||
1. `moviepilot_api` with `operation_id=site.list`
|
||||
2. Ask for missing credentials or two-step code.
|
||||
3. `update_site_cookie`
|
||||
4. `test_site`
|
||||
3. `moviepilot_api` with `operation_id=site.cookie.update`
|
||||
4. `moviepilot_api` with `operation_id=site.test`
|
||||
|
||||
User: `这个页面按钮点一下后截图给我看`
|
||||
|
||||
|
||||
@@ -1,75 +1,31 @@
|
||||
---
|
||||
name: command-dispatch
|
||||
version: 1
|
||||
version: 2
|
||||
description: >-
|
||||
Use this skill when the user's intent is to execute a system or plugin function. Applicable scenarios include:
|
||||
1) The user sends a slash command starting with / (e.g. /cookiecloud, /sites, /subscribes, etc.);
|
||||
2) The user describes an action in natural language that can be fulfilled by a system or plugin command
|
||||
(e.g. "sync sites", "show subscriptions", "refresh subscriptions", "check downloads", etc.).
|
||||
This skill helps you identify the user's intent, find the matching command, extract necessary parameters,
|
||||
and execute the corresponding command.
|
||||
allowed-tools: list_slash_commands query_plugin_capabilities run_slash_command
|
||||
Use this skill when the user sends a slash command or asks to run a MoviePilot
|
||||
system or plugin command in natural language. Discover the live command
|
||||
catalog, resolve the exact command, and dispatch it through the structured API
|
||||
gateway.
|
||||
allowed-tools: moviepilot_api
|
||||
allowed-api-operations: slash.list slash.run plugin.capabilities
|
||||
---
|
||||
|
||||
# Command Dispatch
|
||||
|
||||
Use this skill to identify user intent and dispatch the corresponding system or plugin command.
|
||||
Use `moviepilot_api`; retired command tools and MCP aliases do not exist.
|
||||
|
||||
## When to Use
|
||||
1. For a literal `/command ...`, preserve the command text and inspect the live
|
||||
catalog with `slash.list` when availability or syntax is uncertain.
|
||||
2. For natural-language requests, call `slash.list`, then match the returned
|
||||
name, description, and category. If a plugin owns the capability, optionally
|
||||
call `plugin.capabilities` for its declared commands and actions.
|
||||
3. Never invent a command or argument. If the live catalog has no match, report
|
||||
that no supported command was found.
|
||||
4. Command execution changes external state. Obtain confirmation unless the user
|
||||
already explicitly requested the exact command/action.
|
||||
5. Dispatch with `slash.run` and `body.command` containing the complete command,
|
||||
for example `/sites disable 3`. Check the returned acceptance/result before
|
||||
reporting completion.
|
||||
|
||||
- The user sends a `/xxx` slash command (execute directly)
|
||||
- The user describes an action in natural language, for example:
|
||||
- "Sync sites" → `/cookiecloud`
|
||||
- "Show my subscriptions" → `/subscribes`
|
||||
- "Refresh subscriptions" → `/subscribes refresh`
|
||||
- "What's downloading?" → `/downloading`
|
||||
- "Organize downloaded files" → `/transfer`
|
||||
- "Clear cache" → `/clear_cache`
|
||||
- "Restart the system" → `/restart`
|
||||
- "Pause all QB tasks" → `/pause_torrents` (plugin command)
|
||||
|
||||
## Tools
|
||||
|
||||
- `list_slash_commands` — List all available slash commands (system + plugin), returns command name, description, and category
|
||||
- `query_plugin_capabilities` — Query detailed plugin capabilities (commands, actions, scheduled services)
|
||||
- `run_slash_command` — Execute a specified command (works for both system and plugin commands)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Identify User Intent
|
||||
|
||||
Determine whether the user's message is requesting the execution of a command:
|
||||
|
||||
- **Direct command**: Message starts with `/`, e.g. `/sites`, `/subscribes` → skip to Step 3
|
||||
- **Natural language**: The user describes an actionable request → continue to Step 2
|
||||
|
||||
### Step 2: Find Matching Command
|
||||
|
||||
Use `list_slash_commands` to retrieve all available commands. Match the user's described intent against the `description` and `category` fields of each command.
|
||||
|
||||
If the user's description involves a specific plugin's functionality, additionally use `query_plugin_capabilities` to query that plugin's detailed capabilities.
|
||||
|
||||
**Matching strategy**:
|
||||
- Prefer exact matches on command description
|
||||
- Then narrow down by category and match
|
||||
- If no matching command is found, inform the user that no corresponding function is available
|
||||
|
||||
### Step 3: Extract Parameters and Execute
|
||||
|
||||
Some commands support additional arguments (space-separated after the command), for example:
|
||||
- `/redo <history_id>` — Manually re-organize a specific record
|
||||
- `/sites disable <site_id>` — Disable one or more sites
|
||||
- `/subscribes delete <subscribe_id>` — Delete one or more subscriptions
|
||||
|
||||
Use `run_slash_command` to execute the command in the format `/command_name arg1 arg2`.
|
||||
|
||||
### Step 4: Report Result
|
||||
|
||||
Command execution is asynchronous. After triggering, inform the user that the command has started. If the command does not exist, list available commands for reference.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Command execution requires admin privileges; the tool will automatically check permissions
|
||||
- Both system and plugin commands are executed via the `run_slash_command` tool — no need to distinguish between them
|
||||
- If you are unsure which command matches the user's intent, use `list_slash_commands` first to look up before deciding
|
||||
- Never guess non-existent commands; always select from the available command list
|
||||
Examples of intent mapping such as site sync, subscription refresh, cache clear,
|
||||
or restart are hints only; the live `slash.list` response is authoritative.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: create-moviepilot-plugin
|
||||
version: 4
|
||||
version: 5
|
||||
description: >-
|
||||
Use this skill when the user asks to create, modify, debug, validate, or
|
||||
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
|
||||
@@ -11,7 +11,8 @@ description: >-
|
||||
sidebar pages, commands, services, workflow actions, agent tools, and local
|
||||
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
|
||||
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
|
||||
allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
|
||||
allowed-tools: read_file write_file edit_file apply_patch execute_command search_web browse_webpage moviepilot_api
|
||||
allowed-api-operations: config.system.get config.system.update plugin.market plugin.installed plugin.install plugin.reload
|
||||
---
|
||||
|
||||
# Create MoviePilot Plugin
|
||||
@@ -37,9 +38,8 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
## Code Tool Workflow
|
||||
|
||||
- Use `execute_command(action="run")` with `rg` and narrow globs or paths to
|
||||
locate plugin classes, extension points, tests, and package entries. Use
|
||||
`list_directory` only when inspecting one known folder or a configured remote
|
||||
storage backend.
|
||||
locate plugin classes, extension points, tests, package entries, and directory
|
||||
contents.
|
||||
- Read the relevant implementation and adjacent example before editing.
|
||||
- If `read_file` reports truncation, continue with smaller `start_line` and
|
||||
`end_line` ranges until all relevant sections have been inspected.
|
||||
@@ -92,21 +92,23 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
- Do not silently default to either mode just because one seems easier.
|
||||
3. Inspect existing plugins before creating a new one:
|
||||
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
|
||||
- Market/local source candidates: use `query_market_plugins` when the
|
||||
running instance is available.
|
||||
- Installed plugin candidates: use `query_installed_plugins`; its summaries
|
||||
- Market/local source candidates: call `moviepilot_api` with
|
||||
`operation_id=plugin.market` when the running instance is available.
|
||||
- Installed plugin candidates: use `operation_id=plugin.installed`; its summaries
|
||||
include `repo_url` when the source can be matched from a local plugin
|
||||
repository or plugin market metadata.
|
||||
- For Vue federation examples, prefer current compliant plugins such as
|
||||
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
|
||||
`MoviePilot-Frontend/examples/plugin-component/`.
|
||||
4. Determine the target source path:
|
||||
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
|
||||
- Query `PLUGIN_LOCAL_REPO_PATHS` with `operation_id=config.system.get` when possible.
|
||||
- If exactly one local plugin repository is configured, prefer that path.
|
||||
- If several are configured, choose the one the user named; otherwise ask
|
||||
which repository to use.
|
||||
- If none is configured, set it before writing plugin code:
|
||||
`update_system_settings(setting_key="PLUGIN_LOCAL_REPO_PATHS", value="local-plugins", operation="replace")`.
|
||||
call `operation_id=config.system.update` with a body containing
|
||||
`setting_key="PLUGIN_LOCAL_REPO_PATHS"`, `value="local-plugins"`, and
|
||||
`operation="replace"`.
|
||||
`local-plugins` is resolved relative to the MoviePilot root by the local
|
||||
plugin source loader. Create that source directory and write the plugin
|
||||
under it; do not write new plugin source directly into `app/plugins/`
|
||||
@@ -476,13 +478,16 @@ Vue API calls:
|
||||
## Local Install And Reload
|
||||
|
||||
1. After writing files in a configured local plugin repository, call
|
||||
`query_market_plugins(query="<PluginID>", force_refresh=True)` to confirm the
|
||||
`moviepilot_api` with `operation_id=plugin.market` and query fields
|
||||
`query="<PluginID>"`, `force_refresh=true` to confirm the
|
||||
local source is visible.
|
||||
2. Install or reinstall with `install_plugin(plugin_id="<PluginID>", force=True)`.
|
||||
2. Install or reinstall with `operation_id=plugin.install`, path parameter
|
||||
`plugin_id="<PluginID>"`, and `query.force=true`.
|
||||
The install flow copies the source into `app/plugins/<plugin_id_lower>/`.
|
||||
3. If `PLUGIN_AUTO_RELOAD` or development mode is enabled, Python source changes
|
||||
in an installed local plugin can auto-sync and reload. If it is not enabled,
|
||||
call `reload_plugin(plugin_id="<PluginID>")` after editing runtime files.
|
||||
call `operation_id=plugin.reload` with path parameter `plugin_id` after
|
||||
editing runtime files.
|
||||
4. When `requirements.txt` changes, reinstall with `force=True`; reloading alone
|
||||
does not install new dependencies.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: create-moviepilot-skill
|
||||
version: 2
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when the user asks to create, scaffold, update, or review a
|
||||
MoviePilot agent skill. This includes adding a new built-in skill under the
|
||||
@@ -8,7 +8,7 @@ description: >-
|
||||
`SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`,
|
||||
adding helper scripts when needed, and bumping the built-in skill `version`
|
||||
so changes can sync into `config/agent/skills`.
|
||||
allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command
|
||||
allowed-tools: read_file write_file edit_file apply_patch execute_command
|
||||
---
|
||||
|
||||
# Create MoviePilot Skill
|
||||
@@ -74,7 +74,7 @@ name: create-moviepilot-skill
|
||||
version: 1
|
||||
description: >-
|
||||
Explain what the skill does and exactly when to use it.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command
|
||||
allowed-tools: read_file write_file edit_file execute_command
|
||||
---
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 4
|
||||
version: 5
|
||||
description: >-
|
||||
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,
|
||||
@@ -27,8 +27,8 @@ Prefer safer product surfaces first:
|
||||
|
||||
| Request | Preferred skill |
|
||||
|---|---|
|
||||
| Normal local MoviePilot product operation exposed as an MCP tool | `moviepilot-cli` |
|
||||
| Direct REST endpoint call | `moviepilot-api` |
|
||||
| Normal MoviePilot product operation | `moviepilot-api` structured operations |
|
||||
| Operation outside the structured API catalog | A more specific Skill or explicit unsupported result |
|
||||
| Slash commands or plugin/system command dispatch | `command-dispatch` |
|
||||
| Manual file organization | `organize-files` |
|
||||
| Retry failed transfer history records | `transfer-failed-retry` |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: downloader-operation
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when the user asks to inspect, diagnose, or directly control a
|
||||
configured qBittorrent, Transmission, or rTorrent instance. It exposes
|
||||
provider capabilities on demand without adding permanent Agent tools, and is
|
||||
suitable for task files and selection, trackers, peers, queue order, limits,
|
||||
tags, locations, rechecks, direct provider submissions, or batch task control.
|
||||
allowed-tools: execute_command
|
||||
---
|
||||
|
||||
# Downloader Operation
|
||||
|
||||
Use `scripts/mp-downloader.py`. The helper reads MoviePilot's local downloader
|
||||
configuration and credentials itself. Never request, print, or pass a host,
|
||||
username, password, API key, Cookie, or arbitrary URL.
|
||||
|
||||
## Boundary
|
||||
|
||||
- Prefer `moviepilot-api` for ordinary MoviePilot acquisition workflows,
|
||||
especially site search, `download.add`, subscriptions, transfer, history, and
|
||||
canonical library checks.
|
||||
- Use this Skill for downloader-native inspection, diagnosis, advanced task
|
||||
properties, and an explicit request to operate the provider directly.
|
||||
- `tasks.add.direct` bypasses MoviePilot download history, site Cookie handling,
|
||||
path selection, duplicate checks, and transfer orchestration. Use it only when
|
||||
the user explicitly wants direct provider submission.
|
||||
- Paths passed to `tasks.location.set` and `tasks.add.direct` are downloader-side
|
||||
paths, not MoviePilot storage paths.
|
||||
|
||||
## Discover First
|
||||
|
||||
List configured instances without secrets:
|
||||
|
||||
```bash
|
||||
python skills/downloader-operation/scripts/mp-downloader.py instances
|
||||
```
|
||||
|
||||
List the actions supported by all providers or one configured client:
|
||||
|
||||
```bash
|
||||
python skills/downloader-operation/scripts/mp-downloader.py capabilities
|
||||
python skills/downloader-operation/scripts/mp-downloader.py capabilities --client "main-qb"
|
||||
```
|
||||
|
||||
Do not guess a provider-specific action. Call `capabilities` when the current
|
||||
instance, provider, argument contract, or side-effect level is uncertain.
|
||||
|
||||
## Call Shape
|
||||
|
||||
```bash
|
||||
python skills/downloader-operation/scripts/mp-downloader.py call \
|
||||
--client "main-qb" \
|
||||
--action tasks.list \
|
||||
--arguments '{"status":"downloading","limit":20}'
|
||||
```
|
||||
|
||||
The `--arguments` value must be one JSON object. Large reads are paged with
|
||||
`offset` and `limit`; the default limit is 50 and the maximum is 200.
|
||||
|
||||
## Core Actions
|
||||
|
||||
- Read: `tasks.list`, `tasks.files`, `tasks.trackers`, `tasks.tags.get`,
|
||||
`tasks.peers`, `session.stats`, `session.speed_limits.get`,
|
||||
`session.details`, `session.content_layout`.
|
||||
- Reversible writes: `tasks.start`, `tasks.stop`, `tasks.recheck`,
|
||||
`tasks.reannounce`, `tasks.queue.move`, `tasks.properties.set`,
|
||||
`tasks.files.selection.set`, `tasks.force_start.set`, `tasks.location.set`,
|
||||
`tasks.category.set`, `tasks.tags.set`, `tasks.trackers.update`,
|
||||
`session.speed_limits.set`.
|
||||
- External/destructive: `tasks.add.direct`, `tasks.delete`.
|
||||
|
||||
For task actions, use `task_id` for one hash/ID or `task_ids` for a batch. Before
|
||||
deleting data, confirm the exact client, tasks, and `delete_files=true`. Before a
|
||||
direct add, confirm the exact magnet/URL or local torrent file, client, paused
|
||||
state, provider path, tags, and category.
|
||||
|
||||
For `tasks.files.selection.set`, pass provider file indexes from `tasks.files`
|
||||
through `wanted_file_ids` and/or `unwanted_file_ids`; never infer indexes from
|
||||
filenames alone. `session.details` is Transmission-only and
|
||||
`session.content_layout` is qBittorrent-only.
|
||||
|
||||
## Verification
|
||||
|
||||
After a write, query the smallest relevant state: `tasks.list` for status and
|
||||
properties, `tasks.files` for file priority, `tasks.trackers` for trackers, or
|
||||
`session.speed_limits.get` for global limits. Report unsupported provider
|
||||
capabilities explicitly instead of falling back to raw HTTP or arbitrary SDK
|
||||
method calls.
|
||||
@@ -0,0 +1,554 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过 MoviePilot 本机配置调用下载器自身 API 的受控命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
PROJECT_ROOT = SCRIPT_PATH.parents[3]
|
||||
DEFAULT_LIMIT = 50
|
||||
MAX_LIMIT = 200
|
||||
ALL_PROVIDERS = ("qbittorrent", "transmission", "rtorrent")
|
||||
PROVIDER_CLASSES = {
|
||||
"qbittorrent": "app.modules.qbittorrent.qbittorrent:Qbittorrent",
|
||||
"transmission": "app.modules.transmission.transmission:Transmission",
|
||||
"rtorrent": "app.modules.rtorrent.rtorrent:Rtorrent",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionSpec:
|
||||
"""描述一个允许调用的下载器 action。"""
|
||||
|
||||
description: str
|
||||
effect: str
|
||||
providers: tuple[str, ...] = ALL_PROVIDERS
|
||||
required: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self, name: str) -> dict[str, Any]:
|
||||
"""返回不包含实现对象的公开能力描述。"""
|
||||
return {
|
||||
"action": name,
|
||||
"description": self.description,
|
||||
"effect": self.effect,
|
||||
"providers": list(self.providers),
|
||||
"required_arguments": list(self.required),
|
||||
}
|
||||
|
||||
|
||||
ACTIONS: dict[str, ActionSpec] = {
|
||||
"tasks.list": ActionSpec("List and filter downloader tasks.", "safe_read"),
|
||||
"tasks.files": ActionSpec("List files and priorities for one task.", "safe_read", required=("task_id",)),
|
||||
"tasks.files.selection.set": ActionSpec(
|
||||
"Select wanted and unwanted files within one task.",
|
||||
"reversible_write",
|
||||
required=("task_id",),
|
||||
),
|
||||
"tasks.trackers": ActionSpec(
|
||||
"List trackers for one task.", "safe_read", ("qbittorrent", "transmission"), ("task_id",)
|
||||
),
|
||||
"tasks.tags.get": ActionSpec("Read task tags or labels.", "safe_read", required=("task_id",)),
|
||||
"tasks.peers": ActionSpec(
|
||||
"Read qBittorrent peer synchronization data.", "safe_read", ("qbittorrent",), ("task_id",)
|
||||
),
|
||||
"tasks.start": ActionSpec("Start or resume one or more tasks.", "reversible_write", required=("task_ids",)),
|
||||
"tasks.stop": ActionSpec("Pause one or more tasks.", "reversible_write", required=("task_ids",)),
|
||||
"tasks.delete": ActionSpec("Delete tasks and optionally their data.", "destructive_write", required=("task_ids",)),
|
||||
"tasks.recheck": ActionSpec("Force data verification for tasks.", "external_side_effect", required=("task_ids",)),
|
||||
"tasks.reannounce": ActionSpec(
|
||||
"Force tracker reannounce.", "external_side_effect", ("qbittorrent", "transmission"), ("task_ids",)
|
||||
),
|
||||
"tasks.queue.move": ActionSpec(
|
||||
"Move tasks to top, up, down, or bottom of the queue.",
|
||||
"reversible_write",
|
||||
("qbittorrent", "transmission"),
|
||||
("task_ids", "position"),
|
||||
),
|
||||
"tasks.force_start.set": ActionSpec(
|
||||
"Enable or disable qBittorrent force-start for tasks.",
|
||||
"reversible_write",
|
||||
("qbittorrent",),
|
||||
("task_ids", "enabled"),
|
||||
),
|
||||
"tasks.properties.set": ActionSpec(
|
||||
"Set task speed, ratio, or seeding-time limits.", "reversible_write", required=("task_id",)
|
||||
),
|
||||
"tasks.location.set": ActionSpec(
|
||||
"Move or retarget one task to a provider-side path.", "external_side_effect", required=("task_id", "location")
|
||||
),
|
||||
"tasks.category.set": ActionSpec(
|
||||
"Set qBittorrent category.", "reversible_write", ("qbittorrent",), ("task_id", "category")
|
||||
),
|
||||
"tasks.tags.set": ActionSpec("Set or add task tags/labels.", "reversible_write", required=("task_ids", "tags")),
|
||||
"tasks.trackers.update": ActionSpec(
|
||||
"Add or replace task trackers.", "reversible_write", ("qbittorrent", "transmission"), ("task_id", "trackers")
|
||||
),
|
||||
"tasks.add.direct": ActionSpec(
|
||||
"Submit a magnet, URL, or local torrent file directly to the provider.",
|
||||
"external_side_effect",
|
||||
required=("content",),
|
||||
),
|
||||
"session.stats": ActionSpec("Read provider transfer/session statistics.", "safe_read"),
|
||||
"session.speed_limits.get": ActionSpec("Read global speed limits.", "safe_read", ("qbittorrent", "transmission")),
|
||||
"session.speed_limits.set": ActionSpec(
|
||||
"Set global speed limits in KB/s.", "reversible_write", ("qbittorrent", "transmission")
|
||||
),
|
||||
"session.details": ActionSpec(
|
||||
"Read Transmission session configuration and capacity details.",
|
||||
"safe_read",
|
||||
("transmission",),
|
||||
),
|
||||
"session.content_layout": ActionSpec(
|
||||
"Read qBittorrent's default torrent content layout.",
|
||||
"safe_read",
|
||||
("qbittorrent",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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_configs() -> list[Any]:
|
||||
"""读取并校验本机下载器配置。"""
|
||||
_ensure_project_import()
|
||||
from app.runtime.extensions.service import ServiceConfigHelper
|
||||
|
||||
return ServiceConfigHelper.get_downloader_configs()
|
||||
|
||||
|
||||
def _import_symbol(reference: str) -> type[Any]:
|
||||
"""按审核过的模块引用惰性导入 provider client。"""
|
||||
module_name, symbol_name = reference.split(":", 1)
|
||||
return getattr(importlib.import_module(module_name), symbol_name)
|
||||
|
||||
|
||||
def _select_config(client_name: Optional[str]) -> Any:
|
||||
"""按实例名选择启用配置,省略名称时使用默认或唯一实例。"""
|
||||
enabled = [config for config in _load_configs() if config.enabled]
|
||||
if client_name:
|
||||
for config in enabled:
|
||||
if config.name == client_name:
|
||||
return config
|
||||
raise ValueError(f"未找到已启用下载器实例: {client_name}")
|
||||
defaults = [config for config in enabled if config.default]
|
||||
if len(defaults) == 1:
|
||||
return defaults[0]
|
||||
if len(enabled) == 1:
|
||||
return enabled[0]
|
||||
raise ValueError("存在多个下载器实例,请显式提供 --client")
|
||||
|
||||
|
||||
def _build_client(config: Any) -> Any:
|
||||
"""使用本机私有配置构造具体下载器 API client。"""
|
||||
provider = str(config.type or "").strip().lower()
|
||||
reference = PROVIDER_CLASSES.get(provider)
|
||||
if not reference:
|
||||
raise ValueError(f"暂不支持下载器类型: {provider or 'unknown'}")
|
||||
client = _import_symbol(reference)(**dict(config.config or {}))
|
||||
if client.is_inactive():
|
||||
client.reconnect()
|
||||
if client.is_inactive():
|
||||
raise RuntimeError("下载器连接不可用")
|
||||
return client
|
||||
|
||||
|
||||
def _jsonable(value: Any, *, depth: int = 0) -> Any:
|
||||
"""将 provider DTO 转成有界、无私有字段的 JSON 值。"""
|
||||
if depth > 6:
|
||||
return str(value)
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Path):
|
||||
return value.as_posix()
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return _jsonable(model_dump(mode="json"), depth=depth + 1)
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): _jsonable(item, depth=depth + 1)
|
||||
for key, item in value.items()
|
||||
if not str(key).startswith("_") and not _is_sensitive_key(str(key))
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_jsonable(item, depth=depth + 1) for item in value]
|
||||
data = getattr(value, "__dict__", None)
|
||||
if isinstance(data, dict):
|
||||
return _jsonable(data, depth=depth + 1)
|
||||
try:
|
||||
return _jsonable(dict(value), depth=depth + 1)
|
||||
except TypeError, ValueError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
"""识别 provider 结果中的凭据、会话和认证字段。"""
|
||||
normalized = "".join(character for character in key.lower() if character.isalnum())
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"cookie",
|
||||
"authorization",
|
||||
"sessionid",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _page(items: Sequence[Any], arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""对列表结果执行统一偏移分页。"""
|
||||
offset = max(0, int(arguments.get("offset", 0)))
|
||||
limit = min(MAX_LIMIT, max(1, int(arguments.get("limit", DEFAULT_LIMIT))))
|
||||
materialized = list(items)
|
||||
return {
|
||||
"total": len(materialized),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"items": _jsonable(materialized[offset : offset + limit]),
|
||||
}
|
||||
|
||||
|
||||
def _task_ids(arguments: Mapping[str, Any]) -> str | list[str]:
|
||||
"""读取一个或多个任务 ID,并保持 provider 接受的形态。"""
|
||||
raw = arguments.get("task_ids", arguments.get("task_id"))
|
||||
if isinstance(raw, list):
|
||||
values = [str(item) for item in raw if str(item).strip()]
|
||||
if not values:
|
||||
raise ValueError("task_ids 不能为空")
|
||||
return values
|
||||
if raw is None or not str(raw).strip():
|
||||
raise ValueError("必须提供 task_id 或 task_ids")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _require(arguments: Mapping[str, Any], name: str) -> Any:
|
||||
"""读取必填 action 参数。"""
|
||||
value = arguments.get(name)
|
||||
if value is None or value == "" or value == []:
|
||||
raise ValueError(f"缺少必填参数: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _tasks_list(client: Any, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""查询并分页返回下载任务。"""
|
||||
tasks, error = client.get_torrents(
|
||||
ids=arguments.get("task_ids", arguments.get("task_id")),
|
||||
status=arguments.get("status"),
|
||||
tags=arguments.get("tags"),
|
||||
)
|
||||
if error:
|
||||
raise RuntimeError("下载器任务查询失败")
|
||||
return _page(tasks or [], arguments)
|
||||
|
||||
|
||||
def _tags_get(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""按 provider 读取任务标签。"""
|
||||
task_id = str(_require(arguments, "task_id"))
|
||||
getter = getattr(client, "get_torrent_tags", None)
|
||||
if callable(getter):
|
||||
return getter(task_id)
|
||||
tasks, error = client.get_torrents(ids=task_id)
|
||||
if error or not tasks:
|
||||
raise RuntimeError("任务标签查询失败")
|
||||
task = _jsonable(tasks[0])
|
||||
if provider == "qbittorrent":
|
||||
tags = task.get("tags") if isinstance(task, dict) else None
|
||||
return [item.strip() for item in str(tags or "").split(",") if item.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _tags_set(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""按 provider 设置任务标签。"""
|
||||
ids = _task_ids(arguments)
|
||||
tags = [str(item) for item in _require(arguments, "tags")]
|
||||
if provider == "qbittorrent":
|
||||
return client.set_torrents_tag(ids=ids, tags=tags)
|
||||
if provider == "transmission":
|
||||
return client.set_torrent_tag(ids=ids, tags=tags)
|
||||
return client.set_torrents_tag(ids=ids, tags=tags)
|
||||
|
||||
|
||||
def _reannounce(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
|
||||
"""调用 provider SDK 的固定重新汇报动作。"""
|
||||
ids = _task_ids(arguments)
|
||||
if provider == "qbittorrent":
|
||||
client.qbc.torrents_reannounce(torrent_hashes=ids)
|
||||
else:
|
||||
client.trc.reannounce_torrent(ids=ids)
|
||||
return True
|
||||
|
||||
|
||||
def _queue_move(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
|
||||
"""按固定枚举调整任务队列位置。"""
|
||||
ids = _task_ids(arguments)
|
||||
position = str(_require(arguments, "position")).lower()
|
||||
if position not in {"top", "up", "down", "bottom"}:
|
||||
raise ValueError("position 仅支持 top、up、down、bottom")
|
||||
if provider == "qbittorrent":
|
||||
method_name = {
|
||||
"top": "torrents_top_priority",
|
||||
"up": "torrents_increase_priority",
|
||||
"down": "torrents_decrease_priority",
|
||||
"bottom": "torrents_bottom_priority",
|
||||
}[position]
|
||||
method = getattr(client.qbc, method_name)
|
||||
method(torrent_hashes=ids)
|
||||
else:
|
||||
method = getattr(client.trc, f"queue_{position}")
|
||||
method(ids=ids)
|
||||
return True
|
||||
|
||||
|
||||
def _set_file_selection(client: Any, provider: str, arguments: Mapping[str, Any]) -> bool:
|
||||
"""按统一 wanted/unwanted 合同设置任务内文件选择。"""
|
||||
task_id = str(_require(arguments, "task_id"))
|
||||
wanted = [int(item) for item in arguments.get("wanted_file_ids", [])]
|
||||
unwanted = [int(item) for item in arguments.get("unwanted_file_ids", [])]
|
||||
if not wanted and not unwanted:
|
||||
raise ValueError("wanted_file_ids 与 unwanted_file_ids 至少提供一项")
|
||||
if set(wanted) & set(unwanted):
|
||||
raise ValueError("同一文件不能同时设为 wanted 和 unwanted")
|
||||
if provider == "transmission":
|
||||
wanted_ok = not wanted or client.set_files(task_id, wanted)
|
||||
unwanted_ok = not unwanted or client.set_unwanted_files(task_id, unwanted)
|
||||
return bool(wanted_ok and unwanted_ok)
|
||||
wanted_ok = not wanted or client.set_files(
|
||||
torrent_hash=task_id,
|
||||
file_ids=wanted,
|
||||
priority=1,
|
||||
)
|
||||
unwanted_ok = not unwanted or client.set_files(
|
||||
torrent_hash=task_id,
|
||||
file_ids=unwanted,
|
||||
priority=0,
|
||||
)
|
||||
return bool(wanted_ok and unwanted_ok)
|
||||
|
||||
|
||||
def _add_direct(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""将显式内容直接提交到下载器,不接收 Cookie 或连接参数。"""
|
||||
content = _require(arguments, "content")
|
||||
torrent_file = bool(arguments.get("torrent_file"))
|
||||
if torrent_file:
|
||||
content = Path(str(content)).expanduser().resolve().read_bytes()
|
||||
tags = arguments.get("tags")
|
||||
common = {
|
||||
"content": content,
|
||||
"is_paused": bool(arguments.get("paused", False)),
|
||||
"download_dir": arguments.get("download_dir"),
|
||||
}
|
||||
if provider == "qbittorrent":
|
||||
return client.add_torrent(
|
||||
**common,
|
||||
tag=tags,
|
||||
category=arguments.get("category"),
|
||||
)
|
||||
if provider == "transmission":
|
||||
return client.add_torrent(**common, labels=tags)
|
||||
return client.add_torrent(**common, tags=tags)
|
||||
|
||||
|
||||
def _dispatch(client: Any, provider: str, action: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""执行 action 注册表中允许的下载器调用。"""
|
||||
if action == "tasks.list":
|
||||
return _tasks_list(client, arguments)
|
||||
if action == "tasks.files":
|
||||
return _page(client.get_files(str(_require(arguments, "task_id"))) or [], arguments)
|
||||
if action == "tasks.files.selection.set":
|
||||
return _set_file_selection(client, provider, arguments)
|
||||
if action == "tasks.trackers":
|
||||
return client.get_trackers(str(_require(arguments, "task_id")))
|
||||
if action == "tasks.tags.get":
|
||||
return _tags_get(client, provider, arguments)
|
||||
if action == "tasks.tags.set":
|
||||
return _tags_set(client, provider, arguments)
|
||||
if action == "tasks.peers":
|
||||
return client.qbc.sync_torrent_peers(str(_require(arguments, "task_id")))
|
||||
if action == "tasks.start":
|
||||
return client.start_torrents(_task_ids(arguments))
|
||||
if action == "tasks.stop":
|
||||
return client.stop_torrents(_task_ids(arguments))
|
||||
if action == "tasks.delete":
|
||||
return client.delete_torrents(bool(arguments.get("delete_files", False)), _task_ids(arguments))
|
||||
if action == "tasks.recheck":
|
||||
return client.recheck_torrents(_task_ids(arguments))
|
||||
if action == "tasks.reannounce":
|
||||
return _reannounce(client, provider, arguments)
|
||||
if action == "tasks.queue.move":
|
||||
return _queue_move(client, provider, arguments)
|
||||
if action == "tasks.force_start.set":
|
||||
client.qbc.torrents_set_force_start(
|
||||
enable=bool(_require(arguments, "enabled")),
|
||||
torrent_hashes=_task_ids(arguments),
|
||||
)
|
||||
return True
|
||||
if action == "tasks.properties.set":
|
||||
properties = {
|
||||
"hash_string": str(_require(arguments, "task_id")),
|
||||
"upload_limit": arguments.get("upload_limit"),
|
||||
"download_limit": arguments.get("download_limit"),
|
||||
}
|
||||
if provider != "rtorrent":
|
||||
properties.update(
|
||||
{
|
||||
"ratio_limit": arguments.get("ratio_limit"),
|
||||
"seeding_time_limit": arguments.get("seeding_time_limit"),
|
||||
}
|
||||
)
|
||||
return client.change_torrent(**properties)
|
||||
if action == "tasks.location.set":
|
||||
return client.set_torrent_location(
|
||||
str(_require(arguments, "task_id")),
|
||||
str(_require(arguments, "location")),
|
||||
)
|
||||
if action == "tasks.category.set":
|
||||
return client.set_torrent_category(
|
||||
str(_require(arguments, "task_id")),
|
||||
str(_require(arguments, "category")),
|
||||
)
|
||||
if action == "tasks.trackers.update":
|
||||
return client.update_tracker(
|
||||
str(_require(arguments, "task_id")),
|
||||
list(_require(arguments, "trackers")),
|
||||
)
|
||||
if action == "tasks.add.direct":
|
||||
return _add_direct(client, provider, arguments)
|
||||
if action == "session.stats":
|
||||
return client.transfer_info()
|
||||
if action == "session.speed_limits.get":
|
||||
limits = client.get_speed_limit()
|
||||
return {"download_limit": limits[0], "upload_limit": limits[1]} if limits else None
|
||||
if action == "session.speed_limits.set":
|
||||
return client.set_speed_limit(
|
||||
download_limit=arguments.get("download_limit"),
|
||||
upload_limit=arguments.get("upload_limit"),
|
||||
)
|
||||
if action == "session.details":
|
||||
return client.get_session()
|
||||
if action == "session.content_layout":
|
||||
return client.get_content_layout()
|
||||
raise ValueError(f"未知 downloader action: {action}")
|
||||
|
||||
|
||||
def list_instances() -> dict[str, Any]:
|
||||
"""列出下载器实例的非敏感投影。"""
|
||||
instances = [
|
||||
{
|
||||
"name": config.name,
|
||||
"provider": config.type,
|
||||
"enabled": bool(config.enabled),
|
||||
"default": bool(config.default),
|
||||
"path_mapping_count": len(config.path_mapping or []),
|
||||
}
|
||||
for config in _load_configs()
|
||||
]
|
||||
return {"success": True, "instances": instances}
|
||||
|
||||
|
||||
def list_capabilities(client_name: Optional[str]) -> dict[str, Any]:
|
||||
"""返回全部或指定实例支持的 action 清单。"""
|
||||
provider = None
|
||||
if client_name:
|
||||
provider = str(_select_config(client_name).type or "").lower()
|
||||
actions = [spec.to_dict(name) for name, spec in ACTIONS.items() if provider is None or provider in spec.providers]
|
||||
return {
|
||||
"success": True,
|
||||
"client": client_name,
|
||||
"provider": provider,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def call_action(client_name: Optional[str], action: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""校验 action/provider 合同并执行一次受控调用。"""
|
||||
spec = ACTIONS.get(action)
|
||||
if spec is None:
|
||||
raise ValueError(f"未知 downloader action: {action}")
|
||||
config = _select_config(client_name)
|
||||
provider = str(config.type or "").lower()
|
||||
if provider not in spec.providers:
|
||||
raise ValueError(f"{provider} 不支持 action: {action}")
|
||||
for name in spec.required:
|
||||
if name == "task_ids":
|
||||
_task_ids(arguments)
|
||||
else:
|
||||
_require(arguments, name)
|
||||
result = _dispatch(_build_client(config), provider, action, arguments)
|
||||
if spec.effect != "safe_read" and result is False:
|
||||
raise RuntimeError("下载器 action 返回失败")
|
||||
return {
|
||||
"success": True,
|
||||
"client": config.name,
|
||||
"provider": provider,
|
||||
"action": action,
|
||||
"effect": spec.effect,
|
||||
"data": _jsonable(result),
|
||||
}
|
||||
|
||||
|
||||
def _parse_arguments(raw: str) -> dict[str, Any]:
|
||||
"""解析并限制调用参数为单个 JSON 对象。"""
|
||||
value = json.loads(raw or "{}")
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("--arguments 必须是 JSON 对象")
|
||||
return value
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""构建固定命令和参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="MoviePilot downloader API helper")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
subparsers.add_parser("instances", help="list configured instances")
|
||||
capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
|
||||
capabilities.add_argument("--client")
|
||||
call = subparsers.add_parser("call", help="call one allowed action")
|
||||
call.add_argument("--client")
|
||||
call.add_argument("--action", required=True, choices=sorted(ACTIONS))
|
||||
call.add_argument("--arguments", default="{}")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行 CLI 并以稳定 JSON envelope 返回结果。"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "instances":
|
||||
payload = list_instances()
|
||||
elif args.command == "capabilities":
|
||||
payload = list_capabilities(args.client)
|
||||
elif args.command == "call":
|
||||
payload = call_action(args.client, args.action, _parse_arguments(args.arguments))
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
except Exception as error: # noqa: BLE001
|
||||
payload = {
|
||||
"success": False,
|
||||
"error_type": type(error).__name__,
|
||||
"message": str(error) if isinstance(error, ValueError) else "下载器调用失败",
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: feedback-issue
|
||||
version: 8
|
||||
version: 9
|
||||
description: >-
|
||||
Use this skill ONLY when the user EXPLICITLY requests filing an
|
||||
upstream issue for MoviePilot core, frontend, or an installed plugin,
|
||||
@@ -11,7 +11,7 @@ description: >-
|
||||
A bare problem report is not enough: diagnose locally first. This
|
||||
skill uses its own scripts under `scripts/`; it does not add or call
|
||||
dedicated Agent tools for collect / prepare / submit.
|
||||
allowed-tools: read_file list_directory write_file execute_command
|
||||
allowed-tools: read_file write_file execute_command
|
||||
---
|
||||
|
||||
# Feedback Issue (问题反馈)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: generate-identifiers
|
||||
version: 3
|
||||
version: 4
|
||||
description: >-
|
||||
Use this skill when a user provides a torrent name or file name and wants to fix recognition issues,
|
||||
or asks to add/manage custom identifiers (自定义识别词).
|
||||
This skill generates identifier rules based on the WordsMatcher preprocessing logic,
|
||||
checks for duplicates against existing rules, and saves them via MCP tools.
|
||||
checks for duplicates against existing rules, and saves them through the
|
||||
structured MoviePilot API gateway.
|
||||
Because custom identifiers are global, generated rules must default to conservative,
|
||||
sample-specific regex patterns instead of broad matches unless the user explicitly wants global cleanup.
|
||||
Applicable scenarios include:
|
||||
@@ -14,7 +15,8 @@ description: >-
|
||||
3) The user needs episode offset rules for series with non-standard numbering;
|
||||
4) The user wants to force recognition of a specific media by source-native ID;
|
||||
5) The user wants TV recognition to use a specific TMDB episode group.
|
||||
allowed-tools: query_custom_identifiers update_custom_identifiers recognize_media
|
||||
allowed-tools: moviepilot_api
|
||||
allowed-api-operations: config.identifiers.get config.identifiers.update media.recognize
|
||||
---
|
||||
|
||||
# Generate Custom Identifiers (生成自定义识别词)
|
||||
@@ -23,10 +25,10 @@ This skill helps generate custom identifier rules for MoviePilot's media recogni
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
- `query_custom_identifiers` - Query all existing custom identifier rules
|
||||
- `update_custom_identifiers` - Save the updated identifier list (replaces the full list)
|
||||
- `recognize_media` - Test recognition of a torrent title or file path (optional, for verification)
|
||||
Use these `moviepilot_api` operations:
|
||||
- `config.identifiers.get` - Query all existing custom identifier rules
|
||||
- `config.identifiers.update` - Save the updated identifier list (replaces the full list)
|
||||
- `media.recognize` - Test recognition of a torrent title or file path (optional)
|
||||
|
||||
## Supported Rule Formats
|
||||
|
||||
@@ -150,10 +152,10 @@ Write the rule using the appropriate format. Ensure:
|
||||
|
||||
### Step 3: Query Existing Identifiers
|
||||
|
||||
Use the `query_custom_identifiers` tool to get all current rules:
|
||||
Use `moviepilot_api` with `operation_id=config.identifiers.get` to get all current rules:
|
||||
|
||||
```
|
||||
query_custom_identifiers()
|
||||
{"operation_id": "config.identifiers.get"}
|
||||
```
|
||||
|
||||
### Step 4: Check for Duplicates
|
||||
@@ -165,22 +167,24 @@ Compare each new rule against the existing identifiers:
|
||||
|
||||
### Step 5: Save the Updated Identifiers
|
||||
|
||||
Merge new non-duplicate rules into the existing list, then use `update_custom_identifiers` to save the **complete** list:
|
||||
Merge new non-duplicate rules into the existing list, then use
|
||||
`operation_id=config.identifiers.update` to save the **complete** list:
|
||||
|
||||
```
|
||||
update_custom_identifiers(
|
||||
identifiers=["existing rule 1", "existing rule 2", "# new comment", "new rule"]
|
||||
)
|
||||
{
|
||||
"operation_id": "config.identifiers.update",
|
||||
"body": {"identifiers": ["existing rule 1", "existing rule 2", "# new comment", "new rule"]}
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL**: Always include ALL existing rules in the list. This tool replaces the entire list.
|
||||
|
||||
### Step 6: Verify (Optional)
|
||||
|
||||
If the user wants to verify the rule works, use `recognize_media` to test:
|
||||
If the user wants to verify the rule works, use `media.recognize` to test:
|
||||
|
||||
```
|
||||
recognize_media(title="the torrent title to test")
|
||||
{"operation_id": "media.recognize", "query": {"title": "the torrent title to test"}}
|
||||
```
|
||||
|
||||
### Step 7: Report
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: mediaserver-operation
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when the user asks to inspect, diagnose, or directly operate a
|
||||
configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, or Navidrome
|
||||
server. It discovers provider capabilities on demand and supports libraries,
|
||||
native movie/music search, episode coverage, recent media, resume state,
|
||||
playback sessions, statistics, scans, metadata refreshes, and provider play
|
||||
URLs without adding permanent tools.
|
||||
allowed-tools: execute_command
|
||||
---
|
||||
|
||||
# Media Server Operation
|
||||
|
||||
Use `scripts/mp-mediaserver.py`. The helper reads MoviePilot's local server
|
||||
configuration and credentials itself. Never request, print, or pass a host,
|
||||
username, password, API key, token, Cookie, or arbitrary URL.
|
||||
|
||||
## Boundary
|
||||
|
||||
- Keep `library.exists` in `moviepilot-api` for canonical duplicate checks. It
|
||||
aggregates configured servers and applies MoviePilot media identity and music
|
||||
matching rules.
|
||||
- Use this Skill for server-native exploration, diagnostics, playback state,
|
||||
library browsing, scans, and metadata refreshes.
|
||||
- A provider result is not automatically a MoviePilot transfer, subscription,
|
||||
or history fact. Use the appropriate MoviePilot API for those workflows.
|
||||
|
||||
## Discover First
|
||||
|
||||
```bash
|
||||
python skills/mediaserver-operation/scripts/mp-mediaserver.py instances
|
||||
python skills/mediaserver-operation/scripts/mp-mediaserver.py capabilities
|
||||
python skills/mediaserver-operation/scripts/mp-mediaserver.py capabilities --server "living-room"
|
||||
```
|
||||
|
||||
`capabilities` returns namespaced actions, argument requirements, providers, and
|
||||
side-effect levels. Call it before using an unfamiliar server or advanced action.
|
||||
|
||||
## Call Shape
|
||||
|
||||
```bash
|
||||
python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
|
||||
--server "living-room" \
|
||||
--action activity.latest \
|
||||
--arguments '{"limit":20}'
|
||||
```
|
||||
|
||||
The `--arguments` value must be one JSON object. List reads default to 50 items
|
||||
and cap at 200.
|
||||
|
||||
## Actions
|
||||
|
||||
- Read: `server.statistics`, `server.users.count`,
|
||||
`server.user.library_folders`, `libraries.list`, `items.list`, `items.count`,
|
||||
`items.detail`, `items.movies.search`, `items.music.search`,
|
||||
`items.season_episodes`, `activity.latest`, `activity.resume`,
|
||||
`activity.backdrops`, `playback.sessions`, and `playback.url`.
|
||||
- External side effects: `library.scan` and `metadata.refresh`.
|
||||
|
||||
Use the exact `server` and item/library IDs returned by earlier calls. Do not
|
||||
invent IDs or reuse IDs across different server instances. `items.list` expects
|
||||
`parent` for all video providers; Navidrome uses its single music library and
|
||||
does not require a parent. `metadata.refresh` accepts an `items` array matching
|
||||
MoviePilot's refresh item contract (`title`, `year`, `type`, `category`,
|
||||
`target_path`).
|
||||
|
||||
Use `items.movies.search` for provider-native movie lookup,
|
||||
`items.music.search` for a title/artist/album lookup, and
|
||||
`items.season_episodes` when a direct server series ID or exact title is known.
|
||||
These results describe one server only; use `library.exists` when the task needs
|
||||
MoviePilot's canonical cross-server duplicate decision.
|
||||
|
||||
## Safety And Verification
|
||||
|
||||
- Before `library.scan`, confirm the exact server and scan scope/mode.
|
||||
- Before `metadata.refresh`, confirm the exact item list because providers may
|
||||
perform a broad library refresh when a precise item cannot be mapped.
|
||||
- After a scan or refresh, verify with `activity.latest`, `items.detail`, or the
|
||||
smallest relevant library query.
|
||||
- If a provider does not advertise an action, report it as unsupported. Never
|
||||
fall back to raw HTTP, arbitrary SDK methods, or credentials copied from
|
||||
MoviePilot settings.
|
||||
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过 MoviePilot 本机配置调用媒体服务器自身 API 的受控命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve()
|
||||
PROJECT_ROOT = SCRIPT_PATH.parents[3]
|
||||
DEFAULT_LIMIT = 50
|
||||
MAX_LIMIT = 200
|
||||
ALL_PROVIDERS = (
|
||||
"emby",
|
||||
"jellyfin",
|
||||
"plex",
|
||||
"zspace",
|
||||
"ugreen",
|
||||
"trimemedia",
|
||||
"navidrome",
|
||||
)
|
||||
PROVIDER_CLASSES = {
|
||||
"emby": "app.modules.emby.emby:Emby",
|
||||
"jellyfin": "app.modules.jellyfin.jellyfin:Jellyfin",
|
||||
"plex": "app.modules.plex.plex:Plex",
|
||||
"zspace": "app.modules.zspace.zspace:ZSpace",
|
||||
"ugreen": "app.modules.ugreen.ugreen:Ugreen",
|
||||
"trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
|
||||
"navidrome": "app.modules.navidrome.navidrome:Navidrome",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionSpec:
|
||||
"""描述一个允许调用的媒体服务器 action。"""
|
||||
|
||||
description: str
|
||||
effect: str
|
||||
providers: tuple[str, ...] = ALL_PROVIDERS
|
||||
required: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self, name: str) -> dict[str, Any]:
|
||||
"""返回不包含实现对象的公开能力描述。"""
|
||||
return {
|
||||
"action": name,
|
||||
"description": self.description,
|
||||
"effect": self.effect,
|
||||
"providers": list(self.providers),
|
||||
"required_arguments": list(self.required),
|
||||
}
|
||||
|
||||
|
||||
ACTIONS: dict[str, ActionSpec] = {
|
||||
"server.statistics": ActionSpec("Read media counts and provider statistics.", "safe_read"),
|
||||
"server.users.count": ActionSpec(
|
||||
"Read provider user count.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"),
|
||||
),
|
||||
"server.user.library_folders": ActionSpec(
|
||||
"Read the current user's visible library folders.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "zspace"),
|
||||
),
|
||||
"libraries.list": ActionSpec("List visible provider libraries.", "safe_read"),
|
||||
"items.list": ActionSpec("Page items below one library or parent.", "safe_read"),
|
||||
"items.count": ActionSpec("Count items below one library or parent.", "safe_read"),
|
||||
"items.detail": ActionSpec("Read one provider item by native ID.", "safe_read", required=("item_id",)),
|
||||
"items.movies.search": ActionSpec(
|
||||
"Search provider-native movie items by title and optional year.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("title",),
|
||||
),
|
||||
"items.music.search": ActionSpec(
|
||||
"Search provider-native music by title, artist, or album.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "navidrome"),
|
||||
),
|
||||
"items.season_episodes": ActionSpec(
|
||||
"Read native episode coverage for one series and optional season.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
),
|
||||
"activity.latest": ActionSpec("Read recently added provider items.", "safe_read"),
|
||||
"activity.resume": ActionSpec("Read in-progress/resumable provider items.", "safe_read"),
|
||||
"activity.backdrops": ActionSpec(
|
||||
"Read recent provider backdrop images.",
|
||||
"safe_read",
|
||||
("ugreen", "trimemedia"),
|
||||
),
|
||||
"playback.sessions": ActionSpec("Read active playback sessions.", "safe_read", ("emby", "jellyfin", "plex")),
|
||||
"playback.url": ActionSpec("Build the provider play URL for one item.", "safe_read", required=("item_id",)),
|
||||
"library.scan": ActionSpec("Trigger a provider library scan.", "external_side_effect"),
|
||||
"metadata.refresh": ActionSpec(
|
||||
"Refresh provider metadata for mapped items.",
|
||||
"external_side_effect",
|
||||
("emby", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("items",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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_configs() -> list[Any]:
|
||||
"""读取并校验本机媒体服务器配置。"""
|
||||
_ensure_project_import()
|
||||
from app.runtime.extensions.service import ServiceConfigHelper
|
||||
|
||||
return ServiceConfigHelper.get_mediaserver_configs()
|
||||
|
||||
|
||||
def _import_symbol(reference: str) -> type[Any]:
|
||||
"""按审核过的模块引用惰性导入 provider client。"""
|
||||
module_name, symbol_name = reference.split(":", 1)
|
||||
return getattr(importlib.import_module(module_name), symbol_name)
|
||||
|
||||
|
||||
def _select_config(server_name: Optional[str]) -> Any:
|
||||
"""按实例名选择启用配置,省略名称时只接受唯一实例。"""
|
||||
enabled = [config for config in _load_configs() if config.enabled]
|
||||
if server_name:
|
||||
for config in enabled:
|
||||
if config.name == server_name:
|
||||
return config
|
||||
raise ValueError(f"未找到已启用媒体服务器实例: {server_name}")
|
||||
if len(enabled) == 1:
|
||||
return enabled[0]
|
||||
raise ValueError("存在多个媒体服务器实例,请显式提供 --server")
|
||||
|
||||
|
||||
def _build_client(config: Any) -> Any:
|
||||
"""使用本机私有配置构造具体媒体服务器 API client。"""
|
||||
provider = str(config.type or "").strip().lower()
|
||||
reference = PROVIDER_CLASSES.get(provider)
|
||||
if not reference:
|
||||
raise ValueError(f"暂不支持媒体服务器类型: {provider or 'unknown'}")
|
||||
client = _import_symbol(reference)(
|
||||
**dict(config.config or {}),
|
||||
sync_libraries=list(config.sync_libraries or []),
|
||||
)
|
||||
if client.is_inactive():
|
||||
client.reconnect()
|
||||
if client.is_inactive():
|
||||
raise RuntimeError("媒体服务器连接不可用")
|
||||
return client
|
||||
|
||||
|
||||
def _jsonable(value: Any, *, depth: int = 0) -> Any:
|
||||
"""将 provider DTO 转成有界、无私有字段的 JSON 值。"""
|
||||
if depth > 6:
|
||||
return str(value)
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Path):
|
||||
return value.as_posix()
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return _jsonable(model_dump(mode="json"), depth=depth + 1)
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): _jsonable(item, depth=depth + 1)
|
||||
for key, item in value.items()
|
||||
if not str(key).startswith("_") and not _is_sensitive_key(str(key))
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_jsonable(item, depth=depth + 1) for item in value]
|
||||
data = getattr(value, "__dict__", None)
|
||||
if isinstance(data, dict):
|
||||
return _jsonable(data, depth=depth + 1)
|
||||
try:
|
||||
return _jsonable(list(value), depth=depth + 1)
|
||||
except TypeError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
"""识别 provider 结果中的凭据、会话和认证字段。"""
|
||||
normalized = "".join(character for character in key.lower() if character.isalnum())
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"cookie",
|
||||
"authorization",
|
||||
"sessionid",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _require(arguments: Mapping[str, Any], name: str) -> Any:
|
||||
"""读取必填 action 参数。"""
|
||||
value = arguments.get(name)
|
||||
if value is None or value == "" or value == []:
|
||||
raise ValueError(f"缺少必填参数: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _limit(arguments: Mapping[str, Any]) -> int:
|
||||
"""读取安全的分页条数。"""
|
||||
return min(MAX_LIMIT, max(1, int(arguments.get("limit", DEFAULT_LIMIT))))
|
||||
|
||||
|
||||
def _call_supported(method: Any, **kwargs: Any) -> Any:
|
||||
"""仅向 provider 公共方法传递其显式支持的关键字参数。"""
|
||||
parameters = inspect.signature(method).parameters
|
||||
supported = {key: value for key, value in kwargs.items() if key in parameters}
|
||||
return method(**supported)
|
||||
|
||||
|
||||
def _items_list(client: Any, provider: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""按 provider 签名列出媒体条目。"""
|
||||
start = max(0, int(arguments.get("offset", 0)))
|
||||
limit = _limit(arguments)
|
||||
if provider == "navidrome":
|
||||
items = client.get_items(start_index=start, limit=limit)
|
||||
else:
|
||||
parent = _require(arguments, "parent")
|
||||
items = client.get_items(parent=parent, start_index=start, limit=limit)
|
||||
materialized = list(items or [])
|
||||
return {"offset": start, "limit": limit, "items": _jsonable(materialized)}
|
||||
|
||||
|
||||
def _items_count(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""按 provider 签名统计媒体条目。"""
|
||||
if provider == "navidrome":
|
||||
return client.get_items_count(str(arguments.get("parent") or "music"))
|
||||
return client.get_items_count(_require(arguments, "parent"))
|
||||
|
||||
|
||||
def _activity(client: Any, action: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""读取最近入库或继续播放条目。"""
|
||||
method = client.get_latest if action == "activity.latest" else client.get_resume
|
||||
limit = _limit(arguments)
|
||||
return _call_supported(
|
||||
method,
|
||||
num=limit,
|
||||
count=limit,
|
||||
username=arguments.get("username"),
|
||||
)
|
||||
|
||||
|
||||
def _search_music(client: Any, provider: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""按 provider 公共接口查询原生音乐条目。"""
|
||||
title = arguments.get("title")
|
||||
artist = arguments.get("artist")
|
||||
album = arguments.get("album")
|
||||
if not any((title, artist, album)):
|
||||
raise ValueError("title、artist、album 至少提供一项")
|
||||
method = client.search_music if provider == "navidrome" else client.get_music
|
||||
return method(title=title, artist=artist, album=album)
|
||||
|
||||
|
||||
def _season_episodes(client: Any, arguments: Mapping[str, Any]) -> Any:
|
||||
"""查询一个 provider 原生剧集的已入库集数。"""
|
||||
if not arguments.get("item_id") and not arguments.get("title"):
|
||||
raise ValueError("item_id 与 title 至少提供一项")
|
||||
return _call_supported(
|
||||
client.get_tv_episodes,
|
||||
item_id=arguments.get("item_id"),
|
||||
title=arguments.get("title"),
|
||||
year=arguments.get("year"),
|
||||
season=arguments.get("season"),
|
||||
)
|
||||
|
||||
|
||||
def _playback_sessions(client: Any, provider: str) -> Any:
|
||||
"""通过固定 provider 端点读取活动播放会话。"""
|
||||
if provider == "plex":
|
||||
plex = client.get_plex()
|
||||
return plex.sessions() if plex else []
|
||||
if provider == "emby":
|
||||
from app.adapters.network.http import RequestUtils
|
||||
|
||||
response = RequestUtils().get_res(
|
||||
f"{client._host}emby/Sessions",
|
||||
params={"api_key": client._apikey},
|
||||
)
|
||||
else:
|
||||
response = client._request().get_res(f"{client._host}Sessions")
|
||||
return response.json() if response else []
|
||||
|
||||
|
||||
def _refresh_metadata(client: Any, arguments: Mapping[str, Any]) -> Any:
|
||||
"""把结构化刷新条目转换为宿主 DTO 后调用 provider。"""
|
||||
_ensure_project_import()
|
||||
from app.schemas.mediaserver import RefreshMediaItem
|
||||
|
||||
items = [RefreshMediaItem.model_validate(item) for item in _require(arguments, "items")]
|
||||
return client.refresh_library_by_items(items)
|
||||
|
||||
|
||||
def _dispatch(client: Any, provider: str, action: str, arguments: Mapping[str, Any]) -> Any:
|
||||
"""执行 action 注册表中允许的媒体服务器调用。"""
|
||||
if action == "server.statistics":
|
||||
return client.get_medias_count()
|
||||
if action == "server.users.count":
|
||||
return client.get_user_count()
|
||||
if action == "server.user.library_folders":
|
||||
return client.get_user_library_folders()
|
||||
if action == "libraries.list":
|
||||
return _call_supported(
|
||||
client.get_librarys,
|
||||
hidden=bool(arguments.get("hidden", False)),
|
||||
username=arguments.get("username"),
|
||||
)
|
||||
if action == "items.list":
|
||||
return _items_list(client, provider, arguments)
|
||||
if action == "items.count":
|
||||
return _items_count(client, provider, arguments)
|
||||
if action == "items.detail":
|
||||
return client.get_iteminfo(str(_require(arguments, "item_id")))
|
||||
if action == "items.movies.search":
|
||||
return client.get_movies(
|
||||
title=str(_require(arguments, "title")),
|
||||
year=arguments.get("year"),
|
||||
)
|
||||
if action == "items.music.search":
|
||||
return _search_music(client, provider, arguments)
|
||||
if action == "items.season_episodes":
|
||||
return _season_episodes(client, arguments)
|
||||
if action in {"activity.latest", "activity.resume"}:
|
||||
return _activity(client, action, arguments)
|
||||
if action == "activity.backdrops":
|
||||
return client.get_latest_backdrops(
|
||||
num=_limit(arguments),
|
||||
remote=bool(arguments.get("remote", False)),
|
||||
)
|
||||
if action == "playback.sessions":
|
||||
return _playback_sessions(client, provider)
|
||||
if action == "playback.url":
|
||||
return client.get_play_url(str(_require(arguments, "item_id")))
|
||||
if action == "library.scan":
|
||||
return _call_supported(
|
||||
client.refresh_root_library,
|
||||
scan_mode=arguments.get("scan_mode"),
|
||||
)
|
||||
if action == "metadata.refresh":
|
||||
return _refresh_metadata(client, arguments)
|
||||
raise ValueError(f"未知 media server action: {action}")
|
||||
|
||||
|
||||
def list_instances() -> dict[str, Any]:
|
||||
"""列出媒体服务器实例的非敏感投影。"""
|
||||
instances = [
|
||||
{
|
||||
"name": config.name,
|
||||
"provider": config.type,
|
||||
"enabled": bool(config.enabled),
|
||||
"sync_library_count": len(config.sync_libraries or []),
|
||||
}
|
||||
for config in _load_configs()
|
||||
]
|
||||
return {"success": True, "instances": instances}
|
||||
|
||||
|
||||
def list_capabilities(server_name: Optional[str]) -> dict[str, Any]:
|
||||
"""返回全部或指定实例支持的 action 清单。"""
|
||||
provider = None
|
||||
if server_name:
|
||||
provider = str(_select_config(server_name).type or "").lower()
|
||||
actions = [spec.to_dict(name) for name, spec in ACTIONS.items() if provider is None or provider in spec.providers]
|
||||
return {
|
||||
"success": True,
|
||||
"server": server_name,
|
||||
"provider": provider,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def call_action(server_name: Optional[str], action: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""校验 action/provider 合同并执行一次受控调用。"""
|
||||
spec = ACTIONS.get(action)
|
||||
if spec is None:
|
||||
raise ValueError(f"未知 media server action: {action}")
|
||||
config = _select_config(server_name)
|
||||
provider = str(config.type or "").lower()
|
||||
if provider not in spec.providers:
|
||||
raise ValueError(f"{provider} 不支持 action: {action}")
|
||||
for name in spec.required:
|
||||
_require(arguments, name)
|
||||
result = _dispatch(_build_client(config), provider, action, arguments)
|
||||
if spec.effect != "safe_read" and result is False:
|
||||
raise RuntimeError("媒体服务器 action 返回失败")
|
||||
return {
|
||||
"success": True,
|
||||
"server": config.name,
|
||||
"provider": provider,
|
||||
"action": action,
|
||||
"effect": spec.effect,
|
||||
"data": _jsonable(result),
|
||||
}
|
||||
|
||||
|
||||
def _parse_arguments(raw: str) -> dict[str, Any]:
|
||||
"""解析并限制调用参数为单个 JSON 对象。"""
|
||||
value = json.loads(raw or "{}")
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("--arguments 必须是 JSON 对象")
|
||||
return value
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""构建固定命令和参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="MoviePilot media server API helper")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
subparsers.add_parser("instances", help="list configured instances")
|
||||
capabilities = subparsers.add_parser("capabilities", help="list allowed actions")
|
||||
capabilities.add_argument("--server")
|
||||
call = subparsers.add_parser("call", help="call one allowed action")
|
||||
call.add_argument("--server")
|
||||
call.add_argument("--action", required=True, choices=sorted(ACTIONS))
|
||||
call.add_argument("--arguments", default="{}")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行 CLI 并以稳定 JSON envelope 返回结果。"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "instances":
|
||||
payload = list_instances()
|
||||
elif args.command == "capabilities":
|
||||
payload = list_capabilities(args.server)
|
||||
elif args.command == "call":
|
||||
payload = call_action(args.server, args.action, _parse_arguments(args.arguments))
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
except Exception as error: # noqa: BLE001
|
||||
payload = {
|
||||
"success": False,
|
||||
"error_type": type(error).__name__,
|
||||
"message": str(error) if isinstance(error, ValueError) else "媒体服务器调用失败",
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+158
-726
@@ -1,738 +1,170 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 14
|
||||
version: 16
|
||||
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.
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
schedulers, plugins, filter rules, and system settings. It authorizes the
|
||||
structured moviepilot_api gateway only; it does not authorize arbitrary HTTP,
|
||||
legacy Agent tools, MCP compatibility commands, authentication headers, or API
|
||||
tokens.
|
||||
allowed-tools: moviepilot_api
|
||||
allowed-api-operations: >-
|
||||
media.search media.person.search media.person.credits media.recognize media.scrape
|
||||
media.episode_schedule media.detail subscription.add subscription.update
|
||||
subscription.search subscription.list subscription.shares subscription.popular
|
||||
subscription.history subscription.delete download.add download.history.delete
|
||||
transfer.history.delete site.list site.update site.userdata site.test site.cookie.update
|
||||
recommendation.list library.exists
|
||||
storage.settings storage.list transfer.history transfer.file scheduler.list scheduler.run
|
||||
workflow.list workflow.run plugin.installed plugin.market plugin.capabilities
|
||||
plugin.config.get plugin.config.update plugin.reload plugin.install plugin.uninstall
|
||||
slash.list config.identifiers.get config.identifiers.update search.torrents search.results
|
||||
filter.builtin filter.custom filter.groups filter.custom.add
|
||||
filter.custom.update filter.custom.delete filter.group.add filter.group.update
|
||||
filter.group.delete plugin.data config.system.get config.system.update slash.run
|
||||
---
|
||||
|
||||
# MoviePilot REST API
|
||||
# MoviePilot API
|
||||
|
||||
> All script paths are relative to this skill file.
|
||||
Use `moviepilot_api` for normal MoviePilot business operations. The tool accepts
|
||||
only `operation_id`, `path_params`, `query`, and `body`. The host chooses the
|
||||
fixed HTTP method and path, creates the current user's authentication token,
|
||||
applies authorization and confirmation policy, and returns the API response.
|
||||
|
||||
Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly.
|
||||
Never provide a URL, method, authentication header, API key, or access token.
|
||||
Never fall back to a retired tool name or `moviepilot tool` MCP command. If an
|
||||
operation is not listed in this skill, do not simulate it through arbitrary HTTP;
|
||||
use a more specific skill or explain that the structured operation is unavailable.
|
||||
Use `downloader-operation` for downloader instances, task inspection and native
|
||||
task control. Use `mediaserver-operation` for libraries, items, playback sessions,
|
||||
scans, refreshes and other native media-server capabilities.
|
||||
|
||||
Generic media requests use one stable identity contract: `media_source` is a
|
||||
`MediaSource` enum value and `media_id` is that source's native ID. Supply the
|
||||
pair together and keep it unchanged across detail, search, subscription,
|
||||
download, transfer, scraping, and library checks. Source-specific IDs exposed
|
||||
by `MediaInfo` are mapping metadata, not alternate generic request parameters.
|
||||
Native IDs remain valid on explicitly source-owned endpoints under `/tmdb`,
|
||||
`/douban`, `/bangumi`, and `/anilist`.
|
||||
## Calling Contract
|
||||
|
||||
## Scope And Boundaries
|
||||
Call the gateway with this shape:
|
||||
|
||||
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
|
||||
|
||||
When the script runs inside the MoviePilot project, it imports `app.runtime.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.
|
||||
|
||||
Configuration priority:
|
||||
|
||||
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
|
||||
|
||||
### General syntax
|
||||
|
||||
```
|
||||
python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
|
||||
```json
|
||||
{
|
||||
"operation_id": "media.search",
|
||||
"path_params": {},
|
||||
"query": {"title": "流浪地球", "type": "media"},
|
||||
"body": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- 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.
|
||||
|
||||
### API versions and response envelopes
|
||||
|
||||
- `/api/v1` is the only MoviePilot application REST API version; the former
|
||||
`/api/v2` wrapping layer is no longer available.
|
||||
- Every ordinary JSON endpoint returns exactly
|
||||
`{"success":<boolean>,"message":<string>,"data":<endpoint data>}`. Only the
|
||||
`data` schema varies between endpoints, and the concrete envelope is visible
|
||||
in `/docs` and `/api/v1/openapi.json`.
|
||||
- HTTP errors keep their status code and use `success=false`; validation errors
|
||||
include their structured details in `data`.
|
||||
- Send `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` or `Accept-Language` when the
|
||||
response message must match a specific language. The backend returns the
|
||||
translated text directly in `message` and falls back to the original text
|
||||
when no translation exists.
|
||||
- SSE, files, images, HTML, empty responses, OAuth2 login, and OpenAI,
|
||||
Anthropic, or MCP JSON-RPC protocol endpoints keep their protocol-native
|
||||
response body and explicit OpenAPI declaration.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# GET with query params
|
||||
python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="media"
|
||||
|
||||
# POST with JSON body
|
||||
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"Avatar.2009","enclosure":"abc1234:1"},"media_source":"themoviedb","media_id":"19995"}'
|
||||
|
||||
# DELETE
|
||||
python scripts/mp-api.py DELETE /api/v1/subscribe/123
|
||||
|
||||
# Endpoints that require ?token= auth
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
||||
|
||||
# Uniform v1 JSON response envelope
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/cpu
|
||||
```
|
||||
|
||||
## Complete API Reference
|
||||
|
||||
All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `{param}`.
|
||||
|
||||
---
|
||||
|
||||
### Media Search (13 endpoints)
|
||||
|
||||
When recognition omits `media_source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `media_source`, or the complete `media_source` + `media_id` pair, keeps recognition strict to that manually selected source.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/media/search` | Search by title. Params: `title` (required), `type=media|music|collection|person`, `page`, `count`, optional repeated `media_source`; each type accepts only its supported `MediaSource` values. Comma-separated input is legacy compatibility only |
|
||||
| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata such as title and year |
|
||||
| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata |
|
||||
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `media_source` |
|
||||
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `media_source` |
|
||||
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: paired `media_source` + `media_id`, `type_name` (`电影`/`电视剧`/`音乐`), `music_type` |
|
||||
| GET | `/api/v1/media/category/config` | Get category strategy config |
|
||||
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
|
||||
| GET | `/api/v1/media/category` | Get auto-categorization config |
|
||||
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons. TMDB-only endpoint |
|
||||
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups. TMDB-only endpoint, so the native ID parameter is intentional |
|
||||
| GET | `/api/v1/media/seasons` | Get media season info. Use `media_source` + `media_id`, or title discovery with `title` and optional `year`; optional `season` narrows the result |
|
||||
| GET | `/api/v1/media/{media_id}` | Get media detail by native ID. Required params: `media_source`, `type_name` (`电影`/`电视剧`) |
|
||||
|
||||
### TMDB (8 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/tmdb/seasons/{tmdbid}` | All seasons for a TMDB title |
|
||||
| GET | `/api/v1/tmdb/similar/{tmdbid}/{type_name}` | Similar movies/TV shows |
|
||||
| GET | `/api/v1/tmdb/recommend/{tmdbid}/{type_name}` | Recommended movies/TV shows |
|
||||
| GET | `/api/v1/tmdb/collection/{collection_id}` | Collection details. Params: `page`, `count` |
|
||||
| GET | `/api/v1/tmdb/credits/{tmdbid}/{type_name}` | Cast and crew. Params: `page` |
|
||||
| GET | `/api/v1/tmdb/person/{person_id}` | Person details |
|
||||
| GET | `/api/v1/tmdb/person/credits/{person_id}` | Person's filmography. Params: `page` |
|
||||
| GET | `/api/v1/tmdb/{tmdbid}/{season}` | All episodes of a season. Params: `episode_group` |
|
||||
|
||||
### Douban (5 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/douban/{doubanid}` | Douban media detail |
|
||||
| GET | `/api/v1/douban/person/{person_id}` | Person detail |
|
||||
| GET | `/api/v1/douban/person/credits/{person_id}` | Person filmography. Params: `page` |
|
||||
| GET | `/api/v1/douban/credits/{doubanid}/{type_name}` | Cast info (type_name: movie/tv) |
|
||||
| GET | `/api/v1/douban/recommend/{doubanid}/{type_name}` | Recommendations |
|
||||
|
||||
### Bangumi (5 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/bangumi/{bangumiid}` | Bangumi detail |
|
||||
| GET | `/api/v1/bangumi/credits/{bangumiid}` | Cast. Params: `page`, `count` |
|
||||
| GET | `/api/v1/bangumi/recommend/{bangumiid}` | Recommendations. Params: `page`, `count` |
|
||||
| GET | `/api/v1/bangumi/person/{person_id}` | Person detail |
|
||||
| GET | `/api/v1/bangumi/person/credits/{person_id}` | Person filmography. Params: `page`, `count` |
|
||||
|
||||
### AniList (8 endpoints)
|
||||
|
||||
AniList endpoints prefer the `anilist-chinese` proxy and fall back to official AniList GraphQL plus the project's daily translation dataset when the public proxy is unavailable. Media titles prefer the provided Chinese title and fall back to the native-language title.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/anilist/trending` | TRENDING NOW. Params: `page`, `count` |
|
||||
| GET | `/api/v1/anilist/popular-this-season` | POPULAR THIS SEASON. Params: `page`, `count` |
|
||||
| GET | `/api/v1/anilist/discover` | Explore anime. Params: `search`, `genre`, `format`, `season`, `season_year`, `status`, `country`, `sort`, `page`, `count` |
|
||||
| GET | `/api/v1/anilist/{anilist_id}` | AniList media detail |
|
||||
| GET | `/api/v1/anilist/credits/{anilist_id}` | Japanese voice cast. Params: `page`, `count` |
|
||||
| GET | `/api/v1/anilist/recommend/{anilist_id}` | Recommendations. Params: `page`, `count` |
|
||||
| GET | `/api/v1/anilist/person/{person_id}` | Staff detail |
|
||||
| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` |
|
||||
|
||||
### Music (6 entity endpoints plus unified search)
|
||||
|
||||
Music uses the independent `MusicMeta` / `MusicInfo` contract and a
|
||||
source-native MusicBrainz identity. `music_type=recording` is one track,
|
||||
`album` is a multi-track collection, and `artist` is browse-only. MoviePilot
|
||||
searches, recognizes, subscribes to, downloads, organizes, scrapes, and checks
|
||||
music on configured music-capable media servers; it does not manage playlists.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, repeated enum `media_source` |
|
||||
| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `media_source`, `media_id` |
|
||||
| GET | `/api/v1/music/explore` | Explore by `media_source`: MusicBrainz supports `mode=chart|fresh`; Douban Music always uses official tag categories with `tags` and `douban_sort=U|S|R|O`. Other params: `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
|
||||
| GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `media_source` |
|
||||
| GET | `/api/v1/music/album/{album_id}/related` | Related albums for the selected source. Params: `media_source`, `count` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}` | Browse artist detail. Params: `media_source` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/albums` | Browse artist albums/EPs/singles. Params: `media_source`, `page`, `count`, `album_type` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/related` | Browse related artists. Params: `media_source`, `count` |
|
||||
|
||||
Music acquisition rules:
|
||||
|
||||
- Reuse `media_source`, `media_id`, and `music_type` from search/detail results. Never substitute a same-name entity.
|
||||
- Subscribe/download one recording as one track. Subscribe/download one album as a complete multi-track pack.
|
||||
- Album torrent validation compares supported audio files with `total_tracks`; incomplete resources do not complete the subscription.
|
||||
- Artist IDs are never subscription, torrent, download, transfer, or library-existence targets.
|
||||
- `/api/v1/media/scrape/{storage}` writes configured music tags/covers and resolves lyrics from existing sidecars, embedded tags, plugins, LRCLIB, optional authorized Musixmatch, and TheAudioDB plain-text fallback. The default upgrade policy keeps `.lyricsfile.yaml` plus compatible `.lrc` output and never replaces higher-quality synchronized lyrics with plain text. Album lyrics requests have a batch deadline and provider cooldowns; external metadata, cover, exploration, statistics, and lyrics requests use bounded caches in their owning modules/helpers.
|
||||
|
||||
### Search / Torrents / Subtitles (11 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/search/media/{media_id}` | Search torrents by native ID. Required param: `media_source`; other params: `mtype`, `area`, `season`, `sites`, `music_type` |
|
||||
| GET | `/api/v1/search/media/{media_id}/stream` | Stream torrent search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
|
||||
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
|
||||
| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
|
||||
| GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/subtitle/media/{media_id}` | Exact subtitle search by native ID. Required param: `media_source`; other params: `mtype`, `season`, `episode`, `sites` |
|
||||
| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | Stream exact subtitle search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
|
||||
| GET | `/api/v1/search/last` | Get latest search results |
|
||||
| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
|
||||
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
|
||||
|
||||
Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business events; use it only to keep the connection alive. Final `replace` payloads above 48 items are batched: the first event uses `type=replace`, later events use `type=append`, and every batch includes `replace_batch=true`, zero-based `batch_index`, `batch_count`, and final `total_items`. Collect all batches in order and replace the visible result atomically. After a `replace`, the final `done` event omits duplicate `items`.
|
||||
|
||||
### Download (8 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` |
|
||||
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
|
||||
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path`; an unrecognized video or music resource returns `data.requires_confirmation=true`, and the same request may be retried with `allow_unrecognized=true` after explicit user confirmation |
|
||||
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, required `media_source` + `media_id`, optional `save_path` |
|
||||
| GET | `/api/v1/download/start/{hashString}` | Resume download task |
|
||||
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
|
||||
| GET | `/api/v1/download/clients` | List available download clients |
|
||||
| DELETE | `/api/v1/download/{hashString}` | Delete download task. Params: `name` |
|
||||
|
||||
### Subscribe (28 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/subscribe/` | List all subscriptions |
|
||||
| POST | `/api/v1/subscribe/` | Add subscription. An explicit identity is always `media_source` + `media_id`; music also requires `type=音乐` and `music_type=recording|album` |
|
||||
| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
|
||||
| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
|
||||
| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
|
||||
| DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription |
|
||||
| PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) |
|
||||
| GET | `/api/v1/subscribe/media/{media_id}` | Query subscription by native ID. Required param: `media_source`; optional params: `season`, `title`, `music_type` |
|
||||
| DELETE | `/api/v1/subscribe/media/{media_id}` | Delete subscription by native ID. Required param: `media_source`; optional params: `season`, `music_type` |
|
||||
| GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions |
|
||||
| GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription |
|
||||
| GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info |
|
||||
| GET | `/api/v1/subscribe/search` | Search all subscriptions |
|
||||
| GET | `/api/v1/subscribe/search/{subscribe_id}` | Search specific subscription |
|
||||
| POST | `/api/v1/subscribe/seerr` | Overseerr/Jellyseerr notification subscription |
|
||||
| GET | `/api/v1/subscribe/history/{mtype}` | Subscription history. Params: `page`, `count` |
|
||||
| DELETE | `/api/v1/subscribe/history/{history_id}` | Delete subscription history |
|
||||
| GET | `/api/v1/subscribe/popular` | Popular subscriptions. Params: `stype` (required), `page`, `count`, `min_sub`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
|
||||
| GET | `/api/v1/subscribe/user/{username}` | User's subscriptions |
|
||||
| GET | `/api/v1/subscribe/files/{subscribe_id}` | Subscription related files |
|
||||
| POST | `/api/v1/subscribe/share` | Share subscription. Body: SubscribeShare JSON |
|
||||
| DELETE | `/api/v1/subscribe/share/{share_id}` | Delete shared subscription |
|
||||
| POST | `/api/v1/subscribe/fork` | Fork shared subscription. Body: SubscribeShare JSON |
|
||||
| GET | `/api/v1/subscribe/follow` | List followed share users |
|
||||
| POST | `/api/v1/subscribe/follow` | Follow a share user. Params: `share_uid` |
|
||||
| DELETE | `/api/v1/subscribe/follow` | Unfollow a share user. Params: `share_uid` |
|
||||
| GET | `/api/v1/subscribe/shares` | List shared subscriptions. Params: `name`, `page`, `count`, `genre_id`, `min_rating`, `max_rating`, `sort_type` |
|
||||
| GET | `/api/v1/subscribe/share/statistics` | Share statistics |
|
||||
|
||||
### Site (26 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/site/` | List all sites |
|
||||
| GET | `/api/v1/site/media/{media_type}` | List configured active sites compatible with `movie`, `tv`, or `music` searches |
|
||||
| POST | `/api/v1/site/` | Add site. Body: Site JSON |
|
||||
| PUT | `/api/v1/site/` | Update site. Body: Site JSON |
|
||||
| GET | `/api/v1/site/{site_id}` | Site detail by ID |
|
||||
| DELETE | `/api/v1/site/{site_id}` | Delete site |
|
||||
| GET | `/api/v1/site/domain/{site_url}` | Site detail by domain |
|
||||
| GET | `/api/v1/site/cookiecloud` | Sync CookieCloud |
|
||||
| GET | `/api/v1/site/reset` | Reset sites |
|
||||
| POST | `/api/v1/site/priorities` | Batch update site priorities. Body: array |
|
||||
| POST | `/api/v1/site/cookie/{site_id}` | Update site cookie & UA. Body: `SiteCookieUpdate` JSON |
|
||||
| GET | `/api/v1/site/cookie/{site_id}` | Legacy update site cookie & UA. Params: `username`, `password`, `code` |
|
||||
| POST | `/api/v1/site/userdata/{site_id}` | Refresh site user data |
|
||||
| GET | `/api/v1/site/userdata/{site_id}` | Get site user data. Params: `workdate` |
|
||||
| GET | `/api/v1/site/userdata/latest` | All sites latest user data |
|
||||
| GET | `/api/v1/site/test/{site_id}` | Test site connection |
|
||||
| GET | `/api/v1/site/icon/{site_id}` | Site icon |
|
||||
| GET | `/api/v1/site/category/{site_id}` | Site categories |
|
||||
| GET | `/api/v1/site/resource/{site_id}` | Site resources. Params: `keyword`, `cat`, `page` |
|
||||
| GET | `/api/v1/site/statistic/{site_url}` | Specific site statistics |
|
||||
| GET | `/api/v1/site/statistic` | All site statistics |
|
||||
| GET | `/api/v1/site/rss` | RSS subscription sites |
|
||||
| GET | `/api/v1/site/auth` | Check authenticated sites |
|
||||
| POST | `/api/v1/site/auth` | Authenticate a site. Body: SiteAuth |
|
||||
| GET | `/api/v1/site/mapping` | Site domain-to-name mapping |
|
||||
| GET | `/api/v1/site/supporting` | Supported site list |
|
||||
|
||||
### History (5 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/history/download` | Download history, newest first. Params: `page`, `count`. `poster` is the poster image; legacy `image` is the backdrop image. |
|
||||
| DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON |
|
||||
| GET | `/api/v1/history/transfer` | Transfer history, including `src_storage` and `dest_storage` for path labels. Params: `title`, `page`, `count`, `status` |
|
||||
| DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory |
|
||||
| GET | `/api/v1/history/empty/transfer` | Clear all transfer history |
|
||||
|
||||
### Media Server (8 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/mediaserver/play/{itemid}` | Play media online |
|
||||
| GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. A completed miss is `success=true` with an empty `data.item`. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` |
|
||||
| POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON |
|
||||
| POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
|
||||
| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |
|
||||
| GET | `/api/v1/mediaserver/playing` | Currently playing. Params: `server` (required), `count` |
|
||||
| GET | `/api/v1/mediaserver/library` | Library list. Params: `server` (required), `hidden` |
|
||||
| GET | `/api/v1/mediaserver/clients` | Available media servers |
|
||||
|
||||
### Notification (1 endpoint)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/notification/manage` | Unified notification-channel management. Body: ManageRequest JSON `{target, action, params}`; `target` is the channel name, `action` is one of `status`, `refresh_qrcode`, `logout`, `test_connection`, `migrate_cache`, `params` carries channel-specific form fields passed through to the channel module |
|
||||
|
||||
### Storage / Files (7 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/storage/manage` | Unified storage management. Body: ManageRequest JSON `{target, action, params}`; `target` is the storage type, `action` is one of `save_config` (config in `params.conf`), `reset_config`, `generate_qrcode`, `generate_auth_url`, `check_login` (`params.ck`/`params.t`), `usage`, `support_transtype` |
|
||||
| POST | `/api/v1/storage/list` | List directory contents. Params: `sort`. Body: FileItem JSON |
|
||||
| POST | `/api/v1/storage/mkdir` | Create directory. Params: `name` (required). Body: FileItem |
|
||||
| POST | `/api/v1/storage/delete` | Delete file or directory. Body: FileItem JSON |
|
||||
| POST | `/api/v1/storage/download` | Download file. Body: FileItem JSON |
|
||||
| POST | `/api/v1/storage/image` | Preview image. Body: FileItem JSON |
|
||||
| POST | `/api/v1/storage/rename` | Rename file/dir. Params: `new_name` (required), `recursive`. Body: FileItem |
|
||||
|
||||
### Transfer (7 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) |
|
||||
| GET | `/api/v1/transfer/queue` | Transfer queue |
|
||||
| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON |
|
||||
| POST | `/api/v1/transfer/manual/target-path` | Match the manual transfer target from source path and directory configuration. Body: ManualTransferItem JSON; this endpoint does not recognize media |
|
||||
| POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON |
|
||||
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; music directories default to `music_type=album` and files to `music_type=recording` when omitted; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying |
|
||||
| GET | `/api/v1/transfer/now` | Run immediate transfer |
|
||||
|
||||
### Dashboard (19 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/dashboard/statistic` | Media statistics. Params: `name` |
|
||||
| GET | `/api/v1/dashboard/statistic2` | Media statistics (API_TOKEN, use `--token-param`) |
|
||||
| GET | `/api/v1/dashboard/storage` | Local storage space |
|
||||
| GET | `/api/v1/dashboard/storage2` | Local storage space (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/processes` | Process info |
|
||||
| GET | `/api/v1/dashboard/system` | Host name, operating system, MoviePilot runtime, and backend version |
|
||||
| GET | `/api/v1/dashboard/downloader` | Downloader info. Params: `name` |
|
||||
| GET | `/api/v1/dashboard/downloader2` | Downloader info (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/schedule` | Scheduled services |
|
||||
| GET | `/api/v1/dashboard/schedule2` | Scheduled services (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/schedule/{job_id}/progress` | Scheduled service real-time progress |
|
||||
| GET | `/api/v1/dashboard/schedule2/{job_id}/progress` | Scheduled service real-time progress (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/transfer` | Transfer statistics. Params: `days` |
|
||||
| GET | `/api/v1/dashboard/cpu` | CPU usage |
|
||||
| GET | `/api/v1/dashboard/cpu2` | CPU usage (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/memory` | Memory usage |
|
||||
| GET | `/api/v1/dashboard/memory2` | Memory usage (API_TOKEN) |
|
||||
| GET | `/api/v1/dashboard/network` | Network traffic |
|
||||
| GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) |
|
||||
|
||||
### Plugin (25 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` |
|
||||
| GET | `/api/v1/plugin/installed` | List installed plugins |
|
||||
| GET | `/api/v1/plugin/statistic` | Plugin install statistics |
|
||||
| GET | `/api/v1/plugin/rating` | Batch plugin ratings. Params: comma-separated `plugin_ids` |
|
||||
| GET | `/api/v1/plugin/rating/{plugin_id}` | Get average rating, rating count, and this installation's rating |
|
||||
| POST | `/api/v1/plugin/rating/{plugin_id}` | Rate an installed plugin. Body: `{"rating": 4.5}`; range 0.1-5.0 |
|
||||
| GET | `/api/v1/plugin/install/{plugin_id}` | Install plugin. Params: `repo_url`, `force` |
|
||||
| GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin |
|
||||
| GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data |
|
||||
| GET | `/api/v1/plugin/{plugin_id}` | Get plugin config |
|
||||
| PUT | `/api/v1/plugin/{plugin_id}` | Update plugin config. Body: JSON object |
|
||||
| DELETE | `/api/v1/plugin/{plugin_id}` | Uninstall plugin |
|
||||
| POST | `/api/v1/plugin/clone/{plugin_id}` | Clone plugin. Body: JSON object |
|
||||
| GET | `/api/v1/plugin/form/{plugin_id}` | Plugin form page |
|
||||
| GET | `/api/v1/plugin/page/{plugin_id}` | Plugin data page |
|
||||
| GET | `/api/v1/plugin/remotes` | Plugin federation list. Params: `token` (required) |
|
||||
| GET | `/api/v1/plugin/dashboard/meta` | All plugin dashboard metadata |
|
||||
| GET | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | Plugin dashboard by key |
|
||||
| GET | `/api/v1/plugin/dashboard/{plugin_id}` | Plugin dashboard |
|
||||
| GET | `/api/v1/plugin/file/{plugin_id}/{filepath}` | Plugin static file |
|
||||
| GET | `/api/v1/plugin/folders` | Plugin folder config |
|
||||
| POST | `/api/v1/plugin/folders` | Save plugin folder config |
|
||||
| POST | `/api/v1/plugin/folders/{folder_name}` | Create plugin folder |
|
||||
| DELETE | `/api/v1/plugin/folders/{folder_name}` | Delete plugin folder |
|
||||
| PUT | `/api/v1/plugin/folders/{folder_name}/plugins` | Update folder plugins. Body: array |
|
||||
|
||||
### Workflow (16 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/workflow/` | List all workflows |
|
||||
| POST | `/api/v1/workflow/` | Create workflow. Body: Workflow JSON |
|
||||
| GET | `/api/v1/workflow/{workflow_id}` | Workflow detail |
|
||||
| PUT | `/api/v1/workflow/{workflow_id}` | Update workflow. Body: Workflow JSON |
|
||||
| DELETE | `/api/v1/workflow/{workflow_id}` | Delete workflow |
|
||||
| POST | `/api/v1/workflow/{workflow_id}/run` | Run workflow. Params: `from_begin` |
|
||||
| POST | `/api/v1/workflow/{workflow_id}/start` | Enable workflow |
|
||||
| POST | `/api/v1/workflow/{workflow_id}/pause` | Disable workflow |
|
||||
| POST | `/api/v1/workflow/{workflow_id}/reset` | Reset workflow |
|
||||
| GET | `/api/v1/workflow/actions` | List all actions |
|
||||
| GET | `/api/v1/workflow/plugin/actions` | Plugin actions. Params: `plugin_id` |
|
||||
| GET | `/api/v1/workflow/event_types` | List event types |
|
||||
| POST | `/api/v1/workflow/share` | Share workflow. Body: WorkflowShare JSON |
|
||||
| DELETE | `/api/v1/workflow/share/{share_id}` | Delete shared workflow |
|
||||
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
|
||||
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
|
||||
|
||||
### System (28 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/system/env` | Get system configuration, including runtime versions and Rust acceleration availability/enabled status |
|
||||
| POST | `/api/v1/system/env` | Update system configuration. Body: JSON object |
|
||||
| GET | `/api/v1/system/ping` | Check service availability for authenticated users |
|
||||
| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
|
||||
| GET | `/api/v1/system/setting/{key}` | Get system setting |
|
||||
| POST | `/api/v1/system/setting/{key}` | Update system setting |
|
||||
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | Sync plugin market repository URLs from the MoviePilot Wiki and merge with local `PLUGIN_MARKET` |
|
||||
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
|
||||
| GET | `/api/v1/system/global/user` | User-related settings |
|
||||
| GET | `/api/v1/system/restart` | Restart system |
|
||||
| POST | `/api/v1/system/upgrade` | Retained Dev update and restart. Body: `"dev"` |
|
||||
| GET | `/api/v1/system/update/status` | Get Release check, download, or install state |
|
||||
| POST | `/api/v1/system/update/check` | Check the latest stable v3 GitHub Release |
|
||||
| POST | `/api/v1/system/update/download` | Start verified Release packages downloading in the background |
|
||||
| POST | `/api/v1/system/update/install` | Confirm restart and install the prepared Release packages |
|
||||
| GET | `/api/v1/system/runscheduler` | Run scheduled service. Params: `jobid` (required) |
|
||||
| GET | `/api/v1/system/runscheduler2` | Run scheduler (API_TOKEN, use `--token-param`). Params: `jobid` |
|
||||
| GET | `/api/v1/system/modulelist` | List loaded modules |
|
||||
| GET | `/api/v1/system/moduletest/{moduleid}` | Test module availability |
|
||||
| GET | `/api/v1/system/versions` | List all GitHub releases |
|
||||
| GET | `/api/v1/system/ruletest` | Test filter rule. Params: `title` (required), `rulegroup_name` (required), `subtitle` |
|
||||
| GET | `/api/v1/system/nettest` | Test network connectivity. Params: `url` (required), `proxy` (required), `include` |
|
||||
| GET | `/api/v1/system/llm-models` | List LLM models. Params: `provider` (required), `api_key` (required), `base_url` |
|
||||
| GET | `/api/v1/system/progress/{process_type}` | Real-time progress (SSE) |
|
||||
| GET | `/api/v1/system/message` | Real-time messages (SSE). Params: `role` |
|
||||
| GET | `/api/v1/system/logging` | Real-time logs (SSE). Params: `length`, `logfile` |
|
||||
| GET | `/api/v1/system/img/{proxy}` | Image proxy. Params: `imgurl` (required), `cache`, `use_cookies` |
|
||||
| GET | `/api/v1/system/cache/image` | Cached image. Params: `url` (required) |
|
||||
|
||||
### Discover (6 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/discover/source` | Discover data sources |
|
||||
| GET | `/api/v1/discover/bangumi` | Discover Bangumi. Params: `type`, `cat`, `sort`, `year`, `page`, `count` |
|
||||
| GET | `/api/v1/discover/douban_movies` | Discover Douban movies. Params: `sort`, `tags`, `page`, `count` |
|
||||
| GET | `/api/v1/discover/douban_tvs` | Discover Douban TV. Params: `sort`, `tags`, `page`, `count` |
|
||||
| GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` |
|
||||
| GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
|
||||
|
||||
### Recommend (18 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/recommend/source` | Recommendation data sources |
|
||||
| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/music_weekly` | ListenBrainz weekly site-wide music chart. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/music_douban` | Douban new album chart. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_movie_top250` | Douban Top 250 movies. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tv_weekly_chinese` | Douban Chinese TV weekly. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tv_weekly_global` | Douban Global TV weekly. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tv_animation` | Douban animation. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_movie_hot` | Douban hot movies. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tv_hot` | Douban hot TV. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/tmdb_movies` | TMDB movies. Params: `sort_by`, `with_genres`, `page` |
|
||||
| GET | `/api/v1/recommend/tmdb_tvs` | TMDB TV. Params: `sort_by`, `with_genres`, `page` |
|
||||
| GET | `/api/v1/recommend/tmdb_trending` | TMDB trending. Params: `page` |
|
||||
|
||||
### Torrent Cache (5 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/torrent/cache` | Get torrent cache |
|
||||
| DELETE | `/api/v1/torrent/cache` | Clear torrent cache |
|
||||
| DELETE | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | Delete specific torrent cache |
|
||||
| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
|
||||
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Optional paired params: `media_source`, `media_id`; music may also pass `music_type` |
|
||||
|
||||
### Recognition Cache (3 endpoints)
|
||||
|
||||
The list endpoint returns local cache totals plus `shared_recognized` and
|
||||
`shared_recognize_enabled` for the persisted successful shared-recognition count.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/tmdb/cache` | Get TheMovieDb recognition cache statistics |
|
||||
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | Delete one URL-encoded TheMovieDb recognition cache key |
|
||||
| DELETE | `/api/v1/tmdb/cache` | Clear TheMovieDb recognition cache |
|
||||
|
||||
### Message (8 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/message/` | Receive user message. Params: `token`, `source` |
|
||||
| GET | `/api/v1/message/` | Callback verification. Params: `token`, `echostr`, `msg_signature`, `timestamp`, `nonce`, `source` |
|
||||
| POST | `/api/v1/message/web` | Send web message. Params: `text` (required) |
|
||||
| GET | `/api/v1/message/web` | Get web messages. Params: `page`, `count` |
|
||||
| GET | `/api/v1/message/notification` | Get notification history. Params: `page`, `count`; server filters cleared history |
|
||||
| DELETE | `/api/v1/message/notification` | Mark notification history as cleared. Params: `scope` (`all`, `system`, `media`) |
|
||||
| POST | `/api/v1/message/webpush/subscribe` | WebPush subscribe. Body: Subscription JSON |
|
||||
| POST | `/api/v1/message/webpush/send` | Send WebPush notification. Body: SubscriptionMessage JSON |
|
||||
|
||||
### User (10 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/user/` | List all users |
|
||||
| POST | `/api/v1/user/` | Create user. Body: UserCreate JSON |
|
||||
| PUT | `/api/v1/user/` | Update user. Body: UserUpdate JSON |
|
||||
| GET | `/api/v1/user/current` | Current logged-in user |
|
||||
| GET | `/api/v1/user/{username}` | User detail |
|
||||
| DELETE | `/api/v1/user/id/{user_id}` | Delete user by ID |
|
||||
| DELETE | `/api/v1/user/name/{user_name}` | Delete user by username |
|
||||
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data; original filename is returned in `data.filename` |
|
||||
| GET | `/api/v1/user/config/{key}` | Get user config |
|
||||
| POST | `/api/v1/user/config/{key}` | Update user config |
|
||||
|
||||
### Login (3 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
|
||||
| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
|
||||
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
|
||||
|
||||
### MCP Tools (6 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/mcp` | MCP JSON-RPC 2.0 endpoint |
|
||||
| DELETE | `/api/v1/mcp` | Terminate MCP session |
|
||||
| GET | `/api/v1/mcp/tools` | List all exposed tools |
|
||||
| POST | `/api/v1/mcp/tools/call` | Call a tool. Body: `{"tool_name":"...","arguments":{...}}` |
|
||||
| GET | `/api/v1/mcp/tools/{tool_name}` | Get tool definition |
|
||||
| GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema |
|
||||
|
||||
The exposed tool list is dynamic: it includes tools declared by enabled plugins
|
||||
and is refreshed lazily after plugin startup, shutdown, reload, or configuration
|
||||
activation. Clients that cache MCP metadata must request `tools/list` again or
|
||||
reconnect after a plugin lifecycle change.
|
||||
|
||||
### Agent MCP Client (3 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/message/agent/mcp/servers` | List external MCP servers configured for the built-in Agent. Superuser login required |
|
||||
| POST | `/api/v1/message/agent/mcp/servers` | Save external MCP servers for the built-in Agent. Body: `{"servers":[...]}` |
|
||||
| POST | `/api/v1/message/agent/mcp/servers/test` | Test one external MCP server and return discovered tools. Body: `{"server":{...}}` |
|
||||
|
||||
### Webhook (2 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/webhook/` | Webhook message (GET). Params: `token`, `source` |
|
||||
| POST | `/api/v1/webhook/` | Webhook message (POST). Params: `token`, `source` |
|
||||
|
||||
### Servarr Compatibility -- /api/v3 (16 endpoints)
|
||||
|
||||
Radarr/Sonarr compatible API for integration with external tools.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v3/system/status` | System status |
|
||||
| GET | `/api/v3/qualityProfile` | Quality profiles |
|
||||
| GET | `/api/v3/rootfolder` | Root folders |
|
||||
| GET | `/api/v3/tag` | Tags |
|
||||
| GET | `/api/v3/languageprofile` | Languages |
|
||||
| GET | `/api/v3/movie` | All subscribed movies |
|
||||
| POST | `/api/v3/movie` | Add movie subscription. Body: RadarrMovie JSON |
|
||||
| GET | `/api/v3/movie/lookup` | Search movie. Params: `term` (format: `tmdb:123`) |
|
||||
| GET | `/api/v3/movie/{mid}` | Movie detail |
|
||||
| DELETE | `/api/v3/movie/{mid}` | Delete movie subscription |
|
||||
| GET | `/api/v3/series` | All TV series |
|
||||
| POST | `/api/v3/series` | Add TV subscription. Body: SonarrSeries JSON |
|
||||
| PUT | `/api/v3/series` | Update TV subscription. Body: SonarrSeries JSON |
|
||||
| GET | `/api/v3/series/lookup` | Search TV. Params: `term` (format: `tvdb:123`) |
|
||||
| GET | `/api/v3/series/{tid}` | TV detail |
|
||||
| DELETE | `/api/v3/series/{tid}` | Delete TV subscription |
|
||||
|
||||
### CookieCloud -- /cookiecloud (5 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/cookiecloud/` | Root |
|
||||
| POST | `/cookiecloud/` | Root |
|
||||
| POST | `/cookiecloud/update` | Upload cookie data. Body: CookieData JSON |
|
||||
| GET | `/cookiecloud/get/{uuid}` | Download encrypted data |
|
||||
| POST | `/cookiecloud/get/{uuid}` | Download encrypted data (POST) |
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Search and download a movie
|
||||
|
||||
```bash
|
||||
# 1. Search TMDB for the movie
|
||||
python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="media"
|
||||
|
||||
# 2. Get media detail with the exact identity returned by search
|
||||
python scripts/mp-api.py GET /api/v1/media/27205 media_source="themoviedb" type_name="电影"
|
||||
|
||||
# 3. Search torrents
|
||||
python scripts/mp-api.py GET /api/v1/search/media/27205 media_source="themoviedb" mtype="movie"
|
||||
|
||||
# 4. Get latest search results
|
||||
python scripts/mp-api.py GET /api/v1/search/last
|
||||
|
||||
# 5. Add download
|
||||
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"<title_from_search>","enclosure":"<url_from_search>"},"media_source":"themoviedb","media_id":"27205"}'
|
||||
```
|
||||
|
||||
### Search and subscribe to one recording or complete album
|
||||
|
||||
```bash
|
||||
# 1. Search MusicBrainz entities through the unified media search
|
||||
python scripts/mp-api.py GET /api/v1/media/search title="Artist - Title" type="music" count=20
|
||||
|
||||
# 2a. For an album, inspect its complete track list before subscribing
|
||||
python scripts/mp-api.py GET /api/v1/music/album/<album_mbid> media_source="musicbrainz"
|
||||
|
||||
# 2b. Check the exact entity subscription separately; music_type prevents recording/album ambiguity
|
||||
python scripts/mp-api.py GET /api/v1/subscribe/media/<mbid> media_source="musicbrainz" music_type="album"
|
||||
|
||||
# 3. Add one exact album subscription. REST enum values use the localized MediaType value.
|
||||
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Album Title","type":"音乐","music_type":"album","media_source":"musicbrainz","media_id":"<album_mbid>"}'
|
||||
|
||||
# For one track, use that track's recording MBID and music_type=recording instead.
|
||||
```
|
||||
|
||||
Do not create an artist subscription. Select a recording or album from the artist catalog first. For an album manual download, use one matched album resource; the download layer rejects resources whose audio-file list does not cover `total_tracks`.
|
||||
|
||||
### Search and download subtitles
|
||||
|
||||
```bash
|
||||
# 1. Search site subtitles by keyword
|
||||
python scripts/mp-api.py GET /api/v1/search/subtitle/title keyword="Inception" sites="1,2"
|
||||
|
||||
# 2. Restore the last subtitle search with replayable params
|
||||
python scripts/mp-api.py GET /api/v1/search/last/context
|
||||
|
||||
# 3. Download a subtitle result to the recognized media directory
|
||||
python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"media_source":"themoviedb","media_id":"27205"}'
|
||||
```
|
||||
|
||||
### Add a subscription
|
||||
|
||||
```bash
|
||||
# 1. Search for the show
|
||||
python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="media"
|
||||
|
||||
# 2. Check if already subscribed
|
||||
python scripts/mp-api.py GET /api/v1/subscribe/media/1396 media_source="themoviedb"
|
||||
|
||||
# 3. Check if already in library
|
||||
python scripts/mp-api.py GET /api/v1/mediaserver/exists media_source="themoviedb" media_id=1396 mtype="tv"
|
||||
|
||||
# 4. Add subscription
|
||||
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"电视剧","media_source":"themoviedb","media_id":"1396"}'
|
||||
```
|
||||
|
||||
### System monitoring
|
||||
|
||||
```bash
|
||||
# CPU, memory, network
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/cpu
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/memory
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/network
|
||||
|
||||
# Storage
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/storage
|
||||
|
||||
# Active downloads
|
||||
python scripts/mp-api.py GET /api/v1/download/
|
||||
|
||||
# Run a scheduled task
|
||||
python scripts/mp-api.py GET /api/v1/system/runscheduler jobid="subscribe_search_all"
|
||||
```
|
||||
|
||||
### Site management
|
||||
|
||||
```bash
|
||||
# List all sites
|
||||
python scripts/mp-api.py GET /api/v1/site/
|
||||
|
||||
# Test site connectivity
|
||||
python scripts/mp-api.py GET /api/v1/site/test/1
|
||||
|
||||
# Get site user data
|
||||
python scripts/mp-api.py GET /api/v1/site/userdata/1
|
||||
|
||||
# Sync CookieCloud
|
||||
python scripts/mp-api.py GET /api/v1/site/cookiecloud
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| 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 inside the MoviePilot project, or set `MP_HOST` and `MP_API_KEY` in the process environment. |
|
||||
- Put route placeholders such as `subscribe_id`, `hashString`, `plugin_id`,
|
||||
`workflow_id`, `media_id`, `storage`, `rule_id`, and `name` in `path_params`.
|
||||
- Put GET filters and control values in `query`. The gateway also accepts GET
|
||||
values in `body`, but use `query` consistently except for the protected secret
|
||||
flow below.
|
||||
- Put POST, PUT, and PATCH request models in `body`.
|
||||
- Preserve the exact source-native `media_source` + `media_id` returned by a
|
||||
search or detail response. For music, also preserve
|
||||
`music_type=recording|album|artist`; an artist is browse-only.
|
||||
- Treat `success=false`, HTTP error data, empty results, and validation errors as
|
||||
real outcomes. Do not claim success without checking the response.
|
||||
|
||||
## Operation Catalog
|
||||
|
||||
### Media and search
|
||||
|
||||
| Operation | Parameters | Purpose |
|
||||
|---|---|---|
|
||||
| `media.search` | query: `title`, `type`, `page`, `count`, `media_source` | Search video, music, collection, or media entities |
|
||||
| `media.person.search` | query: `title`, `type=person`, paging/source | Search people |
|
||||
| `media.person.credits` | path: `source`, `person_id`; query: paging | Read TMDB or Douban credits |
|
||||
| `media.recognize` | query: `title`, optional `subtitle`, `custom_words`, `media_source` | Recognize a title or file path |
|
||||
| `media.detail` | path: `media_id`; query: `media_source`, `type_name`, music fields when applicable | Read exact media detail |
|
||||
| `media.episode_schedule` | path: `tmdbid`, `season`; query: optional episode group | Read TMDB season episodes |
|
||||
| `recommendation.list` | query: source/category/paging fields | Read the unified recommendation feed |
|
||||
| `search.torrents` | path: `media_id`; query: `media_source`, `mtype`, `season`, `sites`, `music_type` | Search site resources |
|
||||
| `search.results` | query: result filters | Read and filter the latest search context |
|
||||
| `media.scrape` | path: `storage`; query: identity/type; body: file item | Scrape metadata, artwork, and configured music lyrics |
|
||||
|
||||
After `search.torrents`, present the returned filter choices before narrowing
|
||||
results. Reuse `search.results` instead of repeating the same search. Obtain
|
||||
explicit consent before `download.add` or another external side effect.
|
||||
|
||||
### Subscriptions and downloads
|
||||
|
||||
| Operation | Parameters | Purpose |
|
||||
|---|---|---|
|
||||
| `subscription.list` | query filters | List subscriptions |
|
||||
| `subscription.add` | body: subscribe model | Create a subscription |
|
||||
| `subscription.update` | body: updated subscribe model | Update a subscription |
|
||||
| `subscription.search` | path: `subscribe_id` | Trigger/search one subscription |
|
||||
| `subscription.delete` | path: `subscribe_id` | Permanently remove a subscription |
|
||||
| `subscription.history` | path: `mtype`; query paging | Read subscription history |
|
||||
| `subscription.shares` | query paging | Read shared subscriptions |
|
||||
| `subscription.popular` | query paging/type | Read popular subscriptions |
|
||||
| `download.add` | body: torrent input and optional media identity/client/path | Add a download |
|
||||
| `download.history.delete` | query/body accepted by endpoint | Delete download history |
|
||||
|
||||
Before adding a download or subscription, check `library.exists` and
|
||||
`subscription.list` when duplicate risk exists. Deletions and file removal need
|
||||
explicit confirmation.
|
||||
|
||||
### Library, storage, and transfer
|
||||
|
||||
| Operation | Parameters | Purpose |
|
||||
|---|---|---|
|
||||
| `library.exists` | query: exact media identity and type | Check library presence |
|
||||
| `storage.settings` | none | Read configured download/library storage roots |
|
||||
| `storage.list` | body: storage/path/paging/sort fields | List a local or remote storage directory |
|
||||
| `transfer.history` | query filters and paging | Read transfer history |
|
||||
| `transfer.file` | body: manual-transfer model | Organize a file or directory |
|
||||
| `transfer.history.delete` | query/body accepted by endpoint | Submit durable retry or delete legacy history |
|
||||
|
||||
For transfer retries, preserve durable scheduler evidence. If history deletion
|
||||
returns a durable retry decision, stop and report it; only an actually deleted
|
||||
legacy record may be followed by `transfer.file`.
|
||||
|
||||
Downloader task state and media-server library browsing deliberately do not pass
|
||||
through this gateway. Discover the configured instance and its live capability
|
||||
set with the matching provider-operation skill before calling the fixed helper.
|
||||
|
||||
### Sites, workflows, and schedulers
|
||||
|
||||
| Operation | Parameters | Purpose |
|
||||
|---|---|---|
|
||||
| `site.list` | query filters | List configured sites |
|
||||
| `site.userdata` | path: `site_id` | Read site account data |
|
||||
| `site.test` | path: `site_id` | Test site connectivity/login |
|
||||
| `site.update` | body: site model | Update site configuration |
|
||||
| `site.cookie.update` | path: `site_id`; body: credentials/2FA fields | Refresh site authentication |
|
||||
| `scheduler.list` | none | List system/plugin/workflow schedules |
|
||||
| `scheduler.run` | query: `job_id` | Run one scheduler job |
|
||||
| `workflow.list` | query filters | List workflows |
|
||||
| `workflow.run` | path: `workflow_id`; body/query endpoint fields | Run one workflow |
|
||||
|
||||
Scheduler `job_id` values are strings and are unrelated to autonomous Agent
|
||||
task IDs. Test and credential updates are external side effects and require the
|
||||
appropriate authorization/confirmation.
|
||||
|
||||
### Plugins, rules, and configuration
|
||||
|
||||
| Operation | Parameters | Purpose |
|
||||
|---|---|---|
|
||||
| `plugin.installed` / `plugin.market` | query filters | List installed or market plugins |
|
||||
| `plugin.capabilities` | query: optional plugin ID | Read commands, actions, services, and Agent capabilities |
|
||||
| `plugin.config.get` / `plugin.config.update` | path: `plugin_id`; update body | Read or update plugin configuration |
|
||||
| `plugin.data` | path: `plugin_id`; query: key/limit/offset | Read bounded plugin data previews |
|
||||
| `plugin.install` / `plugin.reload` / `plugin.uninstall` | path: `plugin_id` | Manage plugin lifecycle |
|
||||
| `filter.builtin` / `filter.custom` / `filter.groups` | none or query filters | Read filter definitions |
|
||||
| `filter.custom.add` / `filter.custom.update` / `filter.custom.delete` | update/delete path: `rule_id`; body for writes | Manage custom filter rules |
|
||||
| `filter.group.add` / `filter.group.update` / `filter.group.delete` | update/delete path: `name`; body for writes | Manage filter groups |
|
||||
| `config.identifiers.get` / `config.identifiers.update` | update body | Read or replace custom identifiers |
|
||||
| `config.system.get` / `config.system.update` | query/body for read; update body | Read or update system settings |
|
||||
| `slash.list` / `slash.run` | run body: `command` | Discover or dispatch system/plugin slash commands |
|
||||
|
||||
For a raw credential explicitly requested by an administrator, call
|
||||
`config.system.get` with `body.show_secrets=true` and the narrowest
|
||||
`body.setting_key` or `body.group`. The host pauses the turn for confirmation and
|
||||
delivers the value through a protected channel. Never repeat the secret in a
|
||||
normal response.
|
||||
|
||||
Plugin install, reload, uninstall, configuration writes, rule writes, system
|
||||
setting writes, identifier writes, scheduler/workflow runs, and slash commands
|
||||
are state changes. Inspect current state first and confirm unless the user's
|
||||
request already explicitly authorizes the exact action.
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
---
|
||||
name: moviepilot-cli
|
||||
version: 8
|
||||
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 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: `moviepilot tool list`
|
||||
|
||||
Show parameters and usage for a specific command: `moviepilot tool show <command>`
|
||||
|
||||
The tool list includes tools declared by enabled plugins. Re-run `tool list` and
|
||||
`tool show` after a plugin is enabled, disabled, reloaded, or reconfigured so the
|
||||
command selection uses the refreshed runtime registry.
|
||||
|
||||
Always run `show <command>` before calling a command — parameter names are not inferable, do not guess.
|
||||
|
||||
## 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_tasks, query_download_tasks, update_download_tasks, delete_download_tasks, 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, create_agent_task, query_agent_tasks, update_agent_task, run_agent_task, delete_agent_task, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message |
|
||||
|
||||
## Workflows
|
||||
|
||||
### Send a Message
|
||||
|
||||
Run `moviepilot tool show send_message` before sending. For a structured Telegram reply, prefer the optional `rich_message` argument and put the complete GitHub-style Markdown body in it. Headings, lists, tables, blockquotes, code blocks, and links are converted to Telegram Rich Message content. Do not repeat the same content in `message`, `title`, or `image_url`; use those ordinary fields when Rich Message is not needed. Other configured channels receive the Rich Markdown source as their plain-text fallback.
|
||||
|
||||
### Search and Download
|
||||
|
||||
#### 1. Search TMDB
|
||||
|
||||
Search for a movie or TV show by title:
|
||||
`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.
|
||||
|
||||
#### 2. Search torrents
|
||||
|
||||
Reuse the exact `media_source` and `media_id` returned by `search_media`. Do not
|
||||
replace the selected primary identity with an auxiliary TMDB, Douban, Bangumi,
|
||||
or AniList mapping ID.
|
||||
|
||||
Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs:
|
||||
`moviepilot tool run query_sites`
|
||||
|
||||
Search torrents using default sites:
|
||||
`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie"`
|
||||
|
||||
Search torrents using user-specified sites (pass site IDs from `query_sites`):
|
||||
`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie" sites='1,3'`
|
||||
|
||||
When `search_torrents` returns:
|
||||
1. **Stop** — do not call `get_search_results` yet.
|
||||
2. Present all `filter_options` fields and every value within each field to the user verbatim.
|
||||
3. Do not pre-select, summarize, or omit any field or value.
|
||||
4. Wait for the user to select filters or confirm no filters are needed before moving to the next step.
|
||||
|
||||
#### 3. Get filtered results (only after user has responded to filter_options)
|
||||
|
||||
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:
|
||||
`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
|
||||
To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched, and `include_labels=true` when the labels should be returned:
|
||||
`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true include_labels=true`
|
||||
|
||||
If empty, tell the user which filter to relax and ask before retrying.
|
||||
|
||||
#### 4. Present results as a numbered list
|
||||
|
||||
Show all results without pre-selection. Each row: index, title, size, seeders, resolution, release group, `volume_factor`, `freedate_diff`.
|
||||
|
||||
| `volume_factor` | Meaning |
|
||||
|---|---|
|
||||
| `免费` | Free download |
|
||||
| `50%` | 50% download size |
|
||||
| `2X` | Double upload |
|
||||
| `2X免费` | Double upload + free |
|
||||
| `普通` | No discount |
|
||||
|
||||
`freedate_diff`: remaining free window (e.g., `2天3小时`).
|
||||
|
||||
#### 5. Check before downloading
|
||||
|
||||
After the user picks torrents: Run **Check Library and Subscriptions** step.
|
||||
|
||||
If the media already exists in the library or is already subscribed, **stop** and report the finding to the user.
|
||||
|
||||
#### 6. Add download
|
||||
|
||||
Download one or more torrents (`torrent_url` comes from `get_search_results` output):
|
||||
`moviepilot tool run add_download_tasks torrent_url="abc1234:1,def5678:2"`
|
||||
|
||||
#### Error handling
|
||||
|
||||
| Step | Action |
|
||||
|---|---|
|
||||
| `search_media` empty | Retry with an alternative title (English/original), then ask for the title or exact `media_source` + `media_id`. |
|
||||
| `search_torrents` empty | Inform user, ask whether to retry with different sites. |
|
||||
| `get_search_results` empty | Do not silently broaden filters. Suggest which filter to relax, ask before retrying. |
|
||||
| `add_download_tasks` fails | Run `query_downloaders` + `query_download_tasks` to diagnose, then report to user. |
|
||||
|
||||
### Add Subscription
|
||||
|
||||
1. Run `search_media` and keep the returned `media_source` + `media_id` pair.
|
||||
2. Run **Check Library and Subscriptions** step, if media already exists or is subscribed, **stop** and report to user.
|
||||
3. If the user specifies a TV season, run Season Validation step first.
|
||||
|
||||
Subscribe to a movie or TV show:
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009`
|
||||
|
||||
Subscribe to a specific season:
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009 season=4`
|
||||
|
||||
Subscribe starting from a specific episode:
|
||||
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" media_source="themoviedb" media_id=12345 season=1 start_episode=13`
|
||||
|
||||
Subscribe to a complete lossless album and keep upgrading its audio quality:
|
||||
`moviepilot tool run add_subscribe title="..." media_type="music" music_type="album" media_source="musicbrainz" media_id="<release-group-id>" audio_quality="hires|lossless" audio_format="DSD|FLAC|ALAC" min_bit_depth=24 best_version=1`
|
||||
|
||||
Audio bitrate and sample-rate values use bps and Hz. For example, pass `min_bitrate=320000` and `min_sample_rate=96000`.
|
||||
|
||||
### Manage Downloads
|
||||
|
||||
List download tasks and get hash for further operations:
|
||||
`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):
|
||||
`moviepilot tool run update_download_tasks hash=<hash> action=stop upload_limit=512 download_limit=2048`
|
||||
|
||||
Add trackers to a download task:
|
||||
`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):
|
||||
`moviepilot tool run delete_download_tasks hash=<hash>`
|
||||
|
||||
Delete a download task and also remove its files (confirm with user first — irreversible):
|
||||
`moviepilot tool run delete_download_tasks hash=<hash> delete_files=true`
|
||||
|
||||
### Manage Subscriptions
|
||||
|
||||
List active subscriptions:
|
||||
`moviepilot tool run query_subscribes status=R`
|
||||
|
||||
Update subscription filters:
|
||||
`moviepilot tool run update_subscribe subscribe_id=123 resolution="1080p"`
|
||||
|
||||
Only download full-season packs for a TV best-version subscription:
|
||||
`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):
|
||||
`moviepilot tool run search_subscribe subscribe_id=123`
|
||||
|
||||
Remove a subscription (confirm with user first):
|
||||
`moviepilot tool run delete_subscribe subscribe_id=123`
|
||||
|
||||
### Manage Autonomous Agent Tasks
|
||||
|
||||
Use autonomous tasks only when the user explicitly requests delayed, recurring,
|
||||
reminder, or monitoring behavior. Immediate work should run directly. Use the
|
||||
MoviePilot `TZ` setting for local times.
|
||||
|
||||
Scheduled runs reuse the original Agent session context, but user-facing
|
||||
messages are broadcast through MoviePilot's configured notification channels
|
||||
instead of being tied to the channel that created the task. If the Agent sends
|
||||
the complete result with a message tool during execution, it does not send the
|
||||
same final reply again when the run finishes.
|
||||
|
||||
Autonomous task tools use the integer `task_id` returned by
|
||||
`query_agent_tasks`. `query_schedulers` and `run_scheduler` are only for
|
||||
MoviePilot system, plugin, and workflow runtime services and use string
|
||||
`job_id` values; never mix these IDs or use those tools for autonomous tasks.
|
||||
|
||||
For a relative one-time request, use `date` with `delay_minutes`; MoviePilot
|
||||
calculates and persists the exact run time:
|
||||
`moviepilot tool run create_agent_task name="检查电影资源" content="搜索电影《示例电影》是否有资源并报告,不要自动下载。" trigger_type=date delay_minutes=30`
|
||||
|
||||
For a one-time task at a fixed time, use `date` with an ISO 8601 `trigger`:
|
||||
`moviepilot tool run create_agent_task name="今晚检查资源" content="检查目标电影是否有资源并报告。" trigger_type=date trigger="2026-07-19 20:30:00"`
|
||||
|
||||
For recurring work, use a standard five-field cron expression. This example
|
||||
runs every day at 20:30:
|
||||
`moviepilot tool run create_agent_task name="每日资源检查" content="检查目标电影是否有资源并报告。" trigger_type=cron trigger="30 20 * * *"`
|
||||
|
||||
List tasks and inspect `next_run_at` and the latest result:
|
||||
`moviepilot tool run query_agent_tasks`
|
||||
|
||||
Pause or resume a task:
|
||||
`moviepilot tool run update_agent_task task_id=1 enabled=false`
|
||||
|
||||
Queue an enabled task for immediate execution without waiting in the current
|
||||
Agent turn:
|
||||
`moviepilot tool run run_agent_task task_id=1`
|
||||
|
||||
Delete a task only after confirming permanent removal with the user:
|
||||
`moviepilot tool run delete_agent_task task_id=1`
|
||||
|
||||
### Check Library and Subscriptions
|
||||
|
||||
Run before any download or subscription to avoid duplicates.
|
||||
|
||||
Check if the media already exists in the library:
|
||||
`moviepilot tool run query_library_exists media_source="themoviedb" media_id=123456 media_type="movie"`
|
||||
|
||||
Check if the media is already subscribed:
|
||||
`moviepilot tool run query_subscribes media_source="themoviedb" media_id=123456`
|
||||
|
||||
### Season Validation
|
||||
|
||||
Mandatory when user specifies a season. Productions sometimes release a show in multiple parts under one TMDB season; online communities and torrent sites may label each part as a separate "season".
|
||||
|
||||
#### 1. Verify season exists
|
||||
|
||||
Fetch media detail to check available seasons:
|
||||
`moviepilot tool run query_media_detail media_source="themoviedb" media_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.
|
||||
2. If the season does not exist → the user's "season" likely maps to a later episode range within an existing TMDB season. Note the latest (highest-numbered) season from `season_info`, then continue to next step.
|
||||
|
||||
#### 2. Identify the correct episode range
|
||||
|
||||
Fetch the episode schedule for the latest season from `season_info`. This is a
|
||||
TMDB-only tool, so its native `tmdb_id` parameter is intentional:
|
||||
`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 or authentication failure: run `moviepilot doctor` to
|
||||
verify the local MoviePilot installation and settings. Plugin-only log findings
|
||||
remain visible but do not by themselves downgrade the overall Doctor status.
|
||||
Do not ask the user to paste the API key into the prompt for local CLI usage.
|
||||
+47
-170
@@ -1,182 +1,59 @@
|
||||
---
|
||||
name: organize-files
|
||||
version: 3
|
||||
version: 5
|
||||
description: >-
|
||||
Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local video or music files that automatic transfer cannot handle. Typical triggers include manually organizing a file or folder, a TV season pack, one music recording, or a complete album directory. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead.
|
||||
allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file scrape_metadata ask_user_choice send_message
|
||||
Use this skill when the user asks MoviePilot to identify and organize a local
|
||||
or downloaded video/music file, season folder, recording, album directory, or
|
||||
mixed folder that automatic transfer did not handle. If failed transfer
|
||||
history IDs are supplied, use transfer-failed-retry instead.
|
||||
allowed-tools: moviepilot_api execute_command ask_user_choice send_message
|
||||
allowed-api-operations: storage.settings storage.list transfer.history transfer.history.delete media.recognize media.search media.detail library.exists transfer.file media.scrape
|
||||
---
|
||||
|
||||
# Organize Files (智能整理文件)
|
||||
# Organize Files
|
||||
|
||||
Use this skill to help the user identify media files that MoviePilot could not organize automatically, then call the normal transfer pipeline through `transfer_file`. Do not rename, move, or copy files manually; let MoviePilot's directory, transfer mode, rename template, overwrite, scrape, and notification settings handle the actual organization.
|
||||
|
||||
## MoviePilot Transfer Flow
|
||||
|
||||
MoviePilot's normal flow is:
|
||||
|
||||
1. `DownloadChain.download_single` adds a downloader task, records `DownloadHistory` and `DownloadFiles`, runs downloader-specific `download_added`, then sends `DownloadAdded`.
|
||||
2. `TransferChain.process` scans completed downloader tasks in monitored download directories. If a `DownloadHistory` exists for the hash, it reuses the recorded media IDs; otherwise it falls back to path recognition.
|
||||
3. Agent/manual organization calls `transfer_file`, which enters `TransferFileTool` -> `TransferChain.manual_transfer` -> `TransferChain.do_transfer`.
|
||||
4. `do_transfer` recursively collects eligible video/subtitle/audio files, ignores recycle/hidden paths and configured exclude words, and reuses download history when possible. Video uses `MetaInfoPath`; music uses audio tags plus `MetaMusic`/`MusicInfo` and keeps the selected recording or album identity.
|
||||
5. `TransferChain.__handle_transfer` chooses the target directory through `DirectoryHelper`, delegates file operations to the file manager module, and lets `TransHandler` build the final target path and name.
|
||||
6. The callback writes `TransferHistory` success/failure records, emits transfer events, sends notifications, and may trigger `transfer-failed-retry` for failed history records.
|
||||
|
||||
Important implication: an existing `TransferHistory` for the same source path can make a later transfer skip. Delete only stale or failed history records, and only after the user has confirmed the record is safe to remove.
|
||||
Use `moviepilot_api` for every MoviePilot business operation. Retired file,
|
||||
recognition, transfer, and history tools are not available.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Classify The Request
|
||||
1. Establish scope. If the user provides a path, use it. If they identify a
|
||||
downloader task, use `downloader-operation` and its fixed
|
||||
`scripts/mp-downloader.py` helper to discover the instance and call
|
||||
`tasks.list`. If they only name a configured root, call `storage.settings`.
|
||||
Use `storage.list` to inspect the selected directory. Do not process a broad
|
||||
shared root without an explicit, bounded scope.
|
||||
2. Classify files into movie, TV, one music recording, one complete album,
|
||||
subtitle/sidecar, or unrelated content. Do not group unrelated media merely
|
||||
because they share a directory.
|
||||
3. Call `media.recognize` with the representative title or path. If uncertain,
|
||||
call `media.search`; if several exact candidates remain, use
|
||||
`ask_user_choice`. Never invent or translate an ID.
|
||||
4. Preserve the exact `media_source` + `media_id`. For TV, verify season detail
|
||||
with `media.detail` when numbering is ambiguous. For music, a recording is one
|
||||
track, an album is one multi-track directory, and an artist is browse-only.
|
||||
5. When duplicate risk matters, call `library.exists`. If an existing transfer
|
||||
record affects reorganization, inspect `transfer.history`.
|
||||
6. Before a state-changing transfer, summarize the source, target identity,
|
||||
media type, season/music entity, storage, and mode. Continue only when the
|
||||
user's request already authorizes that exact action or after confirmation.
|
||||
7. Call `transfer.file` once per verified unit. For an album, transfer the album
|
||||
directory once only after its supported audio-file count is consistent with
|
||||
the selected album detail.
|
||||
8. If requested, call `media.scrape` after a successful transfer. Report actual
|
||||
tag, cover, and lyrics counts; never assume all lyrics were found.
|
||||
|
||||
- If the user provides one or more failed transfer history IDs, stop and use `transfer-failed-retry`.
|
||||
- If the user provides a path, start from that path.
|
||||
- If the user describes a download task, use `query_download_tasks` to find its save path or hash, then continue with the path.
|
||||
- If the user only says "整理一下下载目录", use `query_directory_settings(directory_type="download")` first, then ask which directory or subdirectory to process if more than one candidate exists.
|
||||
## Structured Calls
|
||||
|
||||
### 2. Inspect Candidate Files
|
||||
- Directory listing: `storage.list` with storage/path/paging/sort in `body`.
|
||||
- Recognition: `media.recognize` with title/path in `query`.
|
||||
- Search: `media.search` with title/type/source constraints in `query`.
|
||||
- Detail: `media.detail` with `path_params.media_id` and identity/type in `query`.
|
||||
- Library check: `library.exists` with the exact identity in `query`.
|
||||
- Transfer: `transfer.file` with the manual-transfer request in `body`.
|
||||
- Scrape: `media.scrape` with `path_params.storage`, file item in `body`, and
|
||||
exact identity/type fields in `query`.
|
||||
|
||||
Use `list_directory` for any directory the user provides. Prefer `sort_by="time"` for "recent" or "刚下载的" requests.
|
||||
|
||||
For directories with more than 20 items, ask the user to narrow the folder or choose the relevant child directory before running transfers. Avoid organizing a broad shared download root unless the user explicitly confirms the scope.
|
||||
|
||||
Treat these as transfer candidates:
|
||||
|
||||
- main media files and Blu-ray folders;
|
||||
- matching subtitle and external audio files in the same media folder;
|
||||
- episode packs where files share the same title/season pattern.
|
||||
- individual supported audio files and album folders containing multiple tracks.
|
||||
|
||||
Skip obvious samples, trailers, screenshots, hidden folders, recycle folders, and files that are not media/subtitle/audio.
|
||||
|
||||
### 3. Identify The Media
|
||||
|
||||
For the best sample file, call:
|
||||
|
||||
```text
|
||||
recognize_media(path="<source file path>")
|
||||
```
|
||||
|
||||
If recognition fails or looks wrong:
|
||||
|
||||
1. Extract likely title, year, media type, season/episode range, or music artist/track/album from filenames and audio tags.
|
||||
2. For video, call `search_media(title="...", year="...", media_type="movie|tv")`. For music, call `search_media(title="<artist> - <title>", media_type="music", music_type="recording|album")`.
|
||||
3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title and `media_source` + `media_id` pair.
|
||||
4. For TV season confusion, use `query_media_detail(media_source="themoviedb", media_id="<id>", media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete.
|
||||
|
||||
Never invent an ID. Preserve the exact source-native entity returned by search: a recording is one track, an album is a multi-track collection, and an artist is browse-only and cannot be organized.
|
||||
|
||||
### 4. Check Existing State
|
||||
|
||||
Before writing:
|
||||
|
||||
- Use `query_library_exists` when a precise video or music identity is known and duplicate risk matters. For albums, an exists result is only true after complete track coverage is confirmed.
|
||||
- Use `query_transfer_history(title="<title or path keyword>", status="all")` if the file may already have a success or failure record.
|
||||
- If `transfer_file` later returns "已整理过", query transfer history, identify the matching source path, and ask before deleting the stale record.
|
||||
|
||||
Only call `delete_transfer_history(history_id=<id>)` for the exact stale/failed record that blocks the requested source path. Do not delete unrelated successful history.
|
||||
|
||||
### 5. Transfer Through MoviePilot
|
||||
|
||||
Use `transfer_file` with explicit identity whenever possible:
|
||||
|
||||
```text
|
||||
transfer_file(
|
||||
file_path="<source path>",
|
||||
storage="local",
|
||||
media_type="movie|tv",
|
||||
media_source="<source>",
|
||||
media_id="<native_id>",
|
||||
season=<season_number_if_tv>
|
||||
)
|
||||
```
|
||||
|
||||
For one recording:
|
||||
|
||||
```text
|
||||
transfer_file(file_path="<audio file>", media_type="music", music_type="recording", media_source="musicbrainz", media_id="<recording_id>")
|
||||
```
|
||||
|
||||
For a complete album, transfer the album directory once:
|
||||
|
||||
```text
|
||||
transfer_file(file_path="<album directory>/", media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- For directories, pass a trailing slash in `file_path` so the tool treats it as a directory.
|
||||
- Prefer leaving `target_path`, `target_storage`, and `transfer_type` empty so configured directory rules apply.
|
||||
- Set `target_path` or `transfer_type` only when the user explicitly asks or the default directory configuration cannot handle the file.
|
||||
- For a single movie or a single TV season folder, transfer the folder once with the shared identity.
|
||||
- For mixed folders, split by media and transfer each file/subfolder separately.
|
||||
- For episode packs, identify the media once, then reuse the exact `media_source` + `media_id`, `media_type="tv"`, and the confirmed `season` for each item.
|
||||
- For one recording, transfer only that audio file with the recording ID.
|
||||
- For one album, verify the directory belongs to the selected album, then transfer the directory once with the album ID. Do not submit every track as an unrelated recording.
|
||||
- Never transfer an artist search result. Select a recording or album first.
|
||||
- When the user asks to refresh music tags, cover, or lyrics after transfer, call `scrape_metadata(media_type="music", ...)`; album scraping may use the album ID and reports actual lyrics counts.
|
||||
|
||||
### 6. Report Clearly
|
||||
|
||||
After each transfer batch, report:
|
||||
|
||||
- source path(s) processed;
|
||||
- recognized media title, type, `media_source` + `media_id`, season/episode range when relevant;
|
||||
- success/failure count;
|
||||
- any failed message exactly enough for the user to act, such as missing media library directory, unsupported storage, existing history, or no media recognized.
|
||||
|
||||
If the result creates failed history records, tell the user they can retry with the history ID or let the agent continue with `transfer-failed-retry`.
|
||||
|
||||
## Common Cases
|
||||
|
||||
### User Gives A Single File
|
||||
|
||||
1. `recognize_media(path=...)`
|
||||
2. If needed, `search_media(...)` and confirm the result.
|
||||
3. `transfer_file(file_path=..., media_type=..., media_source=..., media_id=..., season=...)`
|
||||
|
||||
### User Gives A Season Folder
|
||||
|
||||
1. `list_directory(path=...)`
|
||||
2. Pick a representative episode and run `recognize_media(path=...)`.
|
||||
3. Confirm `media_source`, `media_id`, `media_type="tv"`, and season.
|
||||
4. `transfer_file(file_path="<folder>/", media_type="tv", media_source="<source>", media_id="<native_id>", season=<season>)`
|
||||
|
||||
### User Gives One Music Track
|
||||
|
||||
1. `recognize_media(path=..., media_type="music")`
|
||||
2. Confirm the artist and recording title; use `search_media(..., music_type="recording")` when ambiguous.
|
||||
3. Check the exact recording with `query_library_exists` when duplicate risk matters.
|
||||
4. Transfer the audio file once with the recording `media_source` + `media_id`.
|
||||
|
||||
### User Gives An Album Folder
|
||||
|
||||
1. `list_directory(path=...)` and confirm the files form one album rather than a mixed folder.
|
||||
2. Recognize a representative track, then search/select the album entity and query album detail.
|
||||
3. Compare the folder's supported audio-file count with album `total_tracks`; ask before proceeding when the folder appears incomplete or mixed.
|
||||
4. Check album library existence, then transfer the directory once with `media_type="music"`, `music_type="album"`, and the album identity.
|
||||
5. If requested, scrape the album directory for configured tags, cover, and lyrics; do not claim every lyric was found unless the tool reports it.
|
||||
|
||||
### User Gives A Messy Mixed Folder
|
||||
|
||||
1. `list_directory(path=...)`
|
||||
2. Group candidates by likely title/year/season.
|
||||
3. Confirm groups before writing if there is more than one media.
|
||||
4. Transfer each group separately; do not run one directory transfer over unrelated media.
|
||||
|
||||
### Transfer Says The File Was Already Organized
|
||||
|
||||
1. `query_transfer_history(title="<title or source path keyword>", status="all")`
|
||||
2. Find the exact record with matching `src`.
|
||||
3. Ask the user to confirm deletion if the record is stale or failed.
|
||||
4. `delete_transfer_history(history_id=<id>)`
|
||||
5. Retry `transfer_file(...)`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not use shell commands, raw database edits, or manual filesystem moves for organization.
|
||||
- Do not delete transfer history without an exact matching source path and user confirmation.
|
||||
- Do not use broad download roots as transfer targets unless the user explicitly confirms the scope.
|
||||
- Do not process unrelated media in one directory transfer.
|
||||
- Do not confuse a same-name recording, album, and artist; preserve `music_type` and source-native IDs.
|
||||
- Do not report a partial album as complete or present in the library.
|
||||
- Do not override target directories or transfer modes unless necessary.
|
||||
- Prefer asking one focused question over guessing media identity, season mapping, or destructive cleanup.
|
||||
Stop and report instead of transferring when the source is missing, directory
|
||||
configuration is absent, identity remains ambiguous, an album appears mixed or
|
||||
incomplete, or the requested target would overwrite unrelated media.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: publish-moviepilot-plugin
|
||||
version: 2
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when the user asks to publish, upload, sync, pull, push, diff,
|
||||
or maintain a MoviePilot local plugin in a GitHub repository. Covers using the
|
||||
@@ -12,7 +12,8 @@ description: >-
|
||||
repository when no target repository is available.
|
||||
Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub,
|
||||
从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护.
|
||||
allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command query_system_settings update_system_settings
|
||||
allowed-tools: read_file write_file edit_file apply_patch execute_command moviepilot_api
|
||||
allowed-api-operations: config.system.get config.system.update
|
||||
---
|
||||
|
||||
# Publish MoviePilot Plugin
|
||||
|
||||
@@ -1,197 +1,45 @@
|
||||
---
|
||||
name: transfer-failed-retry
|
||||
version: 4
|
||||
description: Use this skill when you need to retry failed video or music transfers/organizations. Given failed transfer history IDs, query the exact records, group by trustworthy movie/series/recording/album identity, delete only the old records being retried, then re-identify and re-organize through MoviePilot. This skill is automatically triggered when transfer failures occur and AI retry is enabled.
|
||||
allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media
|
||||
version: 5
|
||||
description: >-
|
||||
Use this skill for failed MoviePilot video or music transfer history IDs.
|
||||
Inspect the exact records, preserve durable retry evidence, group only records
|
||||
with a trustworthy shared identity, and re-identify/reorganize only legacy
|
||||
records that were actually deleted.
|
||||
allowed-tools: moviepilot_api
|
||||
allowed-api-operations: transfer.history transfer.history.delete media.recognize media.search transfer.file
|
||||
---
|
||||
|
||||
# Transfer Failed Retry (整理失败重试)
|
||||
# Transfer Failed Retry
|
||||
|
||||
This skill handles retrying failed file transfers/organizations. When file transfers fail, you can use this skill to analyze the failures, remove stale history records, and attempt to re-identify and re-organize the files. It supports both single-file and batch retry scenarios.
|
||||
Use structured `moviepilot_api` operations only.
|
||||
|
||||
## Prerequisites
|
||||
## Required Flow
|
||||
|
||||
You need the following tools:
|
||||
- `query_transfer_history` - Query transfer history records
|
||||
- `delete_transfer_history` - Delete a transfer history record
|
||||
- `recognize_media` - Recognize media info from file path or title
|
||||
- `transfer_file` - Transfer/organize files to the media library
|
||||
- `search_media` - Search video metadata or MusicBrainz recording/album/artist candidates
|
||||
1. Call `transfer.history` with `status=failed` and locate every requested ID.
|
||||
Record source path/storage, destination, mode, exact identity, music type,
|
||||
season/episode, status, and error. Do not act on a different record.
|
||||
2. If the source no longer exists or transfer-directory configuration is
|
||||
missing, stop for that record and report the blocker.
|
||||
3. Group records only when identity and source layout prove they belong to the
|
||||
same movie, series, recording, or album. Same parent directory alone is not
|
||||
enough. Recognize once per verified group.
|
||||
4. Call `transfer.history.delete` for each exact history ID before retrying.
|
||||
This operation may submit a durable record to the persistent retry scheduler
|
||||
instead of deleting it.
|
||||
5. If the result says durable retry was accepted or rejected, that result is
|
||||
final for the current task. Do not call `transfer.file`, delete the target, or
|
||||
remove history/retry evidence.
|
||||
6. Only when the response confirms a legacy history was actually deleted may
|
||||
you continue. First call `media.recognize` with the source path. If the result
|
||||
is absent or unreliable, call `media.search` with narrow filename/tag facts.
|
||||
7. Call `transfer.file` with the original source/storage/mode and the verified
|
||||
source-native identity. Preserve season and music entity fields. For a
|
||||
verified complete album sharing one directory, retry the directory once, not
|
||||
each track as unrelated media.
|
||||
8. Report accepted durable retries, successful legacy retransfers, skipped
|
||||
missing/configuration cases, and remaining failures separately.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Query the Failed Transfer History
|
||||
|
||||
Use `query_transfer_history` to get details about the failed record(s). Filter by status `failed` to find the specific records.
|
||||
|
||||
If you are given a specific history record ID (or multiple IDs), query with those IDs to understand the failure context:
|
||||
|
||||
```
|
||||
query_transfer_history(status="failed")
|
||||
```
|
||||
|
||||
From each record, extract the following key information:
|
||||
- **id**: The history record ID
|
||||
- **src**: Source file path
|
||||
- **title**: The recognized title (may be incorrect)
|
||||
- **errmsg**: The error message explaining why the transfer failed
|
||||
- **type**: Media type (movie/tv/music)
|
||||
- **media_source/media_id**: Exact source-native identity; preserve the pair together for every retry
|
||||
- **seasons/episodes**: Season/episode info (if TV show)
|
||||
- **downloader**: Which downloader was used
|
||||
- **download_hash**: The torrent hash
|
||||
|
||||
### Step 2: Analyze the Failure Reason
|
||||
|
||||
Common failure reasons and how to handle them:
|
||||
|
||||
| Error Message | Cause | Solution |
|
||||
|---------------|-------|----------|
|
||||
| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact `media_source` + `media_id`, then transfer with that pair |
|
||||
| 源目录不存在 | Source file was moved or deleted | Cannot retry - skip this record |
|
||||
| 目标路径不存在 | Target directory issue | Retry transfer - the directory config may have been fixed |
|
||||
| 文件已存在 | Target file already exists | May need to use `force` mode or skip |
|
||||
| 未找到有效的集数信息 | Episode number not recognized | Use `recognize_media` with the file path to get better metadata, or specify season/episode in `transfer_file` |
|
||||
| 未获取到转移目录设置 | No transfer directory configured for this media type | Cannot auto-fix - notify user about directory configuration |
|
||||
|
||||
### Step 3: Delete the Failed History Record(s)
|
||||
|
||||
Before an agent-driven retry, delete the exact failed history record(s) so the cleanup is explicit and auditable. The interactive manual-transfer flow now clears matching failed records automatically, but agent retries retain this confirmation step.
|
||||
|
||||
```
|
||||
delete_transfer_history(history_id=<record_id>)
|
||||
```
|
||||
|
||||
### Step 4: Re-identify and Re-organize
|
||||
|
||||
Based on the failure analysis in Step 2:
|
||||
|
||||
#### Case A: Unrecognized Media (未识别到媒体信息)
|
||||
|
||||
1. Try recognizing the media from file path:
|
||||
```
|
||||
recognize_media(path="<source_file_path>")
|
||||
```
|
||||
|
||||
2. If recognition fails, search the appropriate metadata source with keywords extracted from the filename or audio tags:
|
||||
```
|
||||
search_media(title="<extracted_title>", media_type="movie" or "tv")
|
||||
# or for music
|
||||
search_media(title="<artist> - <track_or_album>", media_type="music", music_type="recording" or "album")
|
||||
```
|
||||
|
||||
3. Once you have the exact identity, re-transfer with explicit identification:
|
||||
```
|
||||
transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="movie" or "tv")
|
||||
# or for music
|
||||
transfer_file(file_path="<source_path>", media_type="music", music_type="recording" or "album", media_source="musicbrainz", media_id="<recording_or_album_id>")
|
||||
```
|
||||
|
||||
#### Case B: Transfer Error (file operation failed)
|
||||
|
||||
Simply retry the transfer:
|
||||
```
|
||||
transfer_file(file_path="<source_path>")
|
||||
```
|
||||
|
||||
#### Case C: Episode Recognition Issue
|
||||
|
||||
For TV shows where episode info couldn't be determined:
|
||||
1. Use `recognize_media` to get better metadata
|
||||
2. Re-transfer with explicit season info:
|
||||
```
|
||||
transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="tv", season=<season_number>)
|
||||
```
|
||||
|
||||
#### Case D: Music Recording Or Album
|
||||
|
||||
1. A recording is one track. Retry the individual audio file with its recording ID.
|
||||
2. An album is a collection like a TV season pack. If several failed tracks share one album directory and album ID, verify the group and retry the directory once with the album ID.
|
||||
3. Never use an artist ID as a transfer target. Search/select a recording or album instead.
|
||||
4. Do not infer that a directory is complete merely because it has multiple files. Preserve the album identity and let the transfer/download pipeline enforce expected-track semantics where available.
|
||||
|
||||
### Step 5: Report Result
|
||||
|
||||
After the retry attempt, report the result:
|
||||
- If successful: Confirm the file(s) have been organized correctly
|
||||
- If failed again: Report the new error and suggest manual intervention
|
||||
- For batch operations: Report a summary (e.g., "成功 8/10,失败 2/10")
|
||||
|
||||
## Batch Processing (批量处理)
|
||||
|
||||
When multiple files fail simultaneously (for example, TV episodes or tracks from one album), the system may trigger one batch retry. Treat the batch as candidates for grouping, not proof that every record has the same identity.
|
||||
|
||||
### Key Optimization Rules for Batch Processing:
|
||||
|
||||
1. **Group first, identify once per verified group**: Group by source directory and exact media identity. Reuse video IDs within one movie/series group and reuse an album ID for tracks from one album. Do not apply one recording ID to multiple different tracks.
|
||||
|
||||
2. **Choose the correct retry unit**: For movies, recordings, and TV episode files, delete and retry each exact failed record/file as needed. For a verified album directory, delete the selected failed records and submit the album directory once rather than repeatedly transferring every track.
|
||||
- Delete each failed history record individually
|
||||
- Transfer each file individually (they have different source paths)
|
||||
|
||||
3. **Stop early if root cause is unfixable**: If the first file fails due to an unfixable issue (e.g., missing directory configuration), skip all remaining files with the same error rather than retrying each one.
|
||||
|
||||
4. **Process in order**: Handle files sequentially to avoid race conditions.
|
||||
|
||||
### Batch Example Flow:
|
||||
|
||||
```
|
||||
# Given failed records: IDs = [42, 43, 44, 45] (4 episodes of the same show)
|
||||
# All have errmsg="未识别到媒体信息"
|
||||
|
||||
# 1. Query all failed records
|
||||
query_transfer_history(status="failed")
|
||||
|
||||
# 2. Identify media ONCE using the first file
|
||||
recognize_media(path="/downloads/Show.Name.S01E01.1080p.mkv")
|
||||
# Found: media_source="themoviedb", media_id="789", media_type="tv"
|
||||
|
||||
# 3. For each record: delete history, then re-transfer
|
||||
delete_transfer_history(history_id=42)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=43)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=44)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
|
||||
|
||||
delete_transfer_history(history_id=45)
|
||||
transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
|
||||
|
||||
# 4. Report summary: "重试完成:4/4 成功"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Always delete the old history record first** in this agent workflow so the destructive cleanup remains explicit, even though the interactive manual-transfer flow can clear failed history automatically.
|
||||
- **Do not retry** if the source file no longer exists (源目录不存在).
|
||||
- **Do not retry** if the error is about missing directory configuration - this requires user intervention.
|
||||
- **For unrecognized media**, always try `recognize_media` with the file path first before falling back to `search_media`.
|
||||
- **Be cautious with TV shows** - ensure the correct season and episode information is used.
|
||||
- **For batch processing**, reuse media identification only inside a verified group. Same source location alone does not prove shared identity.
|
||||
- **For music**, keep recording, album, and artist semantics distinct. Artists are browse-only; albums are multi-track retry units.
|
||||
- When this skill is triggered automatically by the system, it provides the `history_id`(s) directly. Start from Step 1 with those specific IDs.
|
||||
|
||||
## Example: Single File Retry Flow
|
||||
|
||||
```
|
||||
# 1. Query the failed record
|
||||
query_transfer_history(status="failed", page=1)
|
||||
# Found: id=42, src="/downloads/Movie.Name.2024.1080p.mkv", errmsg="未识别到媒体信息"
|
||||
|
||||
# 2. Try to recognize the media from path
|
||||
recognize_media(path="/downloads/Movie.Name.2024.1080p.mkv")
|
||||
# Recognition failed
|
||||
|
||||
# 3. Search TMDB
|
||||
search_media(title="Movie Name", year="2024", media_type="movie")
|
||||
# Found: media_source="themoviedb", media_id="123456"
|
||||
|
||||
# 4. Delete old history record
|
||||
delete_transfer_history(history_id=42)
|
||||
|
||||
# 5. Re-transfer with correct identification
|
||||
transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", media_source="themoviedb", media_id="123456", media_type="movie")
|
||||
# Success!
|
||||
```
|
||||
Never transfer an artist entity. Never reuse one identification across records
|
||||
that do not form a verified group. Never report a queued durable retry as a
|
||||
completed file transfer.
|
||||
|
||||
Reference in New Issue
Block a user