refactor: complete agent skill API contracts

This commit is contained in:
jxxghp
2026-09-01 01:19:45 +08:00
parent 871632af25
commit 6d4d7331d1
53 changed files with 19368 additions and 1541 deletions
+173 -44
View File
@@ -1,6 +1,6 @@
---
name: database-operation
version: 5
version: 6
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,
@@ -9,6 +9,7 @@ description: >-
include data statistics, counts, aggregations, inspecting or fixing records,
cleanup requests, and questions like "how many downloads", "show site stats",
"delete old records", or "why is this subscription stuck".
allowed-tools: execute_command
---
# Database Operation
@@ -38,6 +39,20 @@ Use this skill as the final fallback for data access or mutation. It may run
the bundled script, but broad or destructive writes still require explicit user
authorization.
System settings have two managed sources and should not normally be edited here:
- Runtime `Settings` variables are queried and updated by `moviepilot-api`
operations `config.system.get` / `config.system.update`; updates perform type
conversion and persist to `app.env`.
- `SystemConfigKey` values are stored in the database `systemconfig` table, but
the same API operations must be preferred because they enforce registered
keys, plugin mutation admission, value normalization, secret redaction, and
configuration-change events.
Use direct SQL against `systemconfig` only for an explicitly authorized repair
when the managed API cannot complete the operation. Inspect the exact row first,
avoid broad writes, and verify the managed API can read the repaired value.
## Commands
List tables:
@@ -99,71 +114,185 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
## Core Tables
### downloadhistory
Key columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
`tables` returns the tables that exist in the current instance. The catalog below covers every MoviePilot ORM table plus Alembic metadata. Always treat the live `schema <table>` result as authoritative.
### downloadfiles
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
### `agentchat`
- Purpose: Stores Web Agent and messaging-channel session indexes, titles, previews, and message snapshots.
- Useful queries: Tracing Agent history or context restoration by user, session, or update time.
- Write boundary: Owned by the Agent conversation service; do not rewrite message JSON, counters, or ownership.
- Columns: `id`, `session_id`, `client_session_id`, `user_id`, `username`, `channel`, `source`, `original_chat_id`, `title`, `preview`, `agent_messages`, `display_messages`, `message_count`, `created_at`, `updated_at`
### transferhistory
### `agenttask`
- Purpose: Stores one-shot or recurring Agent task definitions, triggers, and the latest execution summary.
- Useful queries: Inspecting task ownership, enablement, cron/run_at settings, and the latest result.
- Write boundary: Create, update, enable, disable, or delete tasks through the Agent task API.
- Columns: `id`, `name`, `content`, `trigger_type`, `cron_expression`, `run_at`, `enabled`, `user_id`, `username`, `session_id`, `channel`, `source`, `original_chat_id`, `last_status`, `last_run_at`, `last_result`, `last_run_id`, `run_count`, `created_at`, `updated_at`
Music rows persist actual `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, and `bitrate` values read during organization. Bitrate uses bps and sample rate uses Hz.
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date`
### `agenttaskrun`
- Purpose: Stores the input snapshot, status, timestamps, and result of each Agent task execution.
- Useful queries: Auditing one run or correlating a failure with task_id, run_id, and trigger source.
- Write boundary: Execution evidence owned by the task runner; never fabricate rows or edit run status.
- Columns: `id`, `run_id`, `task_id`, `trigger_source`, `name`, `content`, `trigger_type`, `cron_expression`, `run_at`, `user_id`, `username`, `session_id`, `channel`, `message_source`, `original_chat_id`, `status`, `started_at`, `finished_at`, `result`
### downloadfailure
### `alembic_version`
- Purpose: Records the Alembic migration revision currently applied to the database.
- Useful queries: Diagnosing startup migration failures or a database/code revision mismatch.
- Write boundary: Never edit it directly; advance or roll back revisions only through Alembic.
- Columns: `version_num`
Key columns: `id`, `fingerprint`, `type`, `title`, `year`, `media_source`, `media_id`, `seasons`, `episodes`, `site`, `torrent_id`, `downloader`, `error_message`, `retry_count`, `next_retry_at`
### `downloadfailure`
- Purpose: Stores stable fingerprints, media/torrent context, errors, and retry scheduling for failed downloads.
- Useful queries: Analyzing failure causes, retry counts, next retry time, and affected media or sites.
- Write boundary: Owned by download-failure compensation; retry or clean records through its business API.
- Columns: `id`, `fingerprint`, `type`, `title`, `year`, `media_source`, `media_id`, `seasons`, `episodes`, `site`, `site_name`, `torrent_id`, `torrent_name`, `torrent_size`, `downloader`, `source`, `error_message`, `retry_count`, `first_failed_at`, `last_failed_at`, `next_retry_at`
### subscribe
### `downloadfiles`
- Purpose: Maps downloader task hashes to full paths, save directories, relative files, and active state.
- Useful queries: Finding task files by downloader/download_hash or diagnosing savepath associations.
- Write boundary: Maintained by download and transfer flows; do not manually change state or path mappings.
- Columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
Music filters use `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, and `min_sample_rate`. Quality upgrades reuse `current_priority` and persist the current exact values in `current_audio_format`, `current_bitrate`, `current_bit_depth`, and `current_sample_rate`.
Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
### `downloadhistory`
- Purpose: Stores media identity, torrent, downloader, user, and recognition context for submitted downloads.
- Useful queries: Reviewing download history or tracing a media identity or hash back to its source.
- Write boundary: Written by the download use case; delete or correct records through the download-history API.
- Columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `image`, `poster`, `downloader`, `download_hash`, `torrent_name`, `torrent_description`, `torrent_site`, `userid`, `username`, `channel`, `date`, `note`, `media_category`, `episode_group`, `custom_words`
### subscribehistory
### `mediaserveritem`
- Purpose: Stores the local index and canonical media identity projected from media-server libraries.
- Useful queries: Checking library presence, server/library/path placement, and season information.
- Write boundary: This is a rebuildable projection; writes and cleanup belong to media-server synchronization.
- Columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `media_source`, `media_id`, `path`, `seasoninfo`, `note`, `lst_mod_date`
Completed music subscriptions retain both audio filters and the final current-quality snapshot for auditing.
Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `date`, `username`
### `message`
- Purpose: Stores inbound and outbound messages, channels, content, attachments, users, and timestamps.
- Useful queries: Paging notification history, distinguishing direction, or tracing duplicates by source.
- Write boundary: Written by messaging and notification services; clean it through the message API or retention job.
- Columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`, `action`, `note`
### user
Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings`
### `outboxmessage`
- Purpose: Stores externally visible side-effect intents committed atomically with business transactions.
- Useful queries: Diagnosing pending/processing/failed state, leases, attempts, and the last error.
- Write boundary: Owned by the Outbox Dispatcher state machine; never mark completion or delete undelivered events manually.
- Columns: `id`, `event_key`, `topic`, `payload_version`, `payload`, `status`, `attempt`, `next_retry_at`, `lease_until`, `last_error`, `created_at`, `completed_at`
### site
Key columns: `id`, `name`, `domain`, `url`, `pri`, `cookie`, `proxy`, `is_active`, `downloader`, `limit_interval`, `limit_count`
### `passkey`
- Purpose: Stores WebAuthn/PassKey credentials, public keys, signature counters, and activation state.
- Useful queries: Authorized authentication diagnostics such as ownership, activation, and last use.
- Write boundary: Security-sensitive; manage it only through the PassKey API and never disclose credential material.
- Columns: `id`, `user_id`, `credential_id`, `public_key`, `sign_count`, `name`, `aaguid`, `created_at`, `last_used_at`, `is_active`, `transports`
### siteuserdata
Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload`, `download`, `ratio`, `seeding`, `leeching`, `seeding_size`, `updated_day`
### `plugindata`
- Purpose: Stores plugin-owned JSON values isolated by plugin_id and key.
- Useful queries: Diagnosing persistence or migration issues for one explicitly identified plugin and key.
- Write boundary: The plugin owns these values; prefer plugin capabilities or the plugin-data API.
- Columns: `id`, `plugin_id`, `key`, `value`
### sitestatistic
Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`
### `pluginidentity`
- Purpose: Stores trusted source, payload source, version, receipt, and CAS revision for a physical plugin package.
- Useful queries: Auditing source binding, package generation, payload application, or identity conflicts.
- Write boundary: Plugin supply-chain state owned exclusively by installation and update transactions.
- Columns: `id`, `plugin_id`, `normalized_plugin_id`, `trusted_source_type`, `trusted_source_key`, `binding_basis`, `payload_source_type`, `payload_source_key`, `declared_version`, `package_generation`, `declared_metadata`, `payload_receipt`, `revision`, `created_at`, `updated_at`, `bound_at`, `payload_applied_at`
### mediaserveritem
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `media_source`, `media_id`, `path`
### `plugininstallation`
- Purpose: Stores plugin installation phase, membership target, identity revisions, and backup state.
- Useful queries: Diagnosing interrupted installations, rollback conditions, and package or backup presence.
- Write boundary: Owned by the plugin installation state machine; never advance phase or overwrite evidence manually.
- Columns: `id`, `transaction_id`, `plugin_id`, `phase`, `membership_before`, `membership_target`, `identity_before_revision`, `identity_target_revision`, `package_existed`, `persistent_backup_existed`, `created_at`, `updated_at`, `schema_version`
The media-bearing tables above store one primary identity only. Treat
`media_source` and `media_id` as an atomic pair: both are null for an unknown
identity, or both contain a valid source enum value and its native ID. Do not
write source-specific identity columns back into these tables.
### `site`
- Purpose: Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.
- Useful queries: Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure.
- Write boundary: Contains cookies, API keys, and tokens; manage it through the site API.
- Columns: `id`, `name`, `domain`, `url`, `pri`, `rss`, `cookie`, `ua`, `apikey`, `token`, `proxy`, `filter`, `render`, `public`, `note`, `limit_interval`, `limit_count`, `limit_seconds`, `timeout`, `is_active`, `lst_mod_date`, `downloader`
### systemconfig
Key columns: `id`, `key`, `value`
### `siteicon`
- Purpose: Caches site names, domains, icon URLs, and Base64 icon content.
- Useful queries: Diagnosing missing icons, incorrect domain mapping, or cache generation.
- Write boundary: Rebuildable cache owned by site-icon synchronization; direct writes are not recommended.
- Columns: `id`, `name`, `domain`, `url`, `base64`
### userconfig
Key columns: `id`, `username`, `key`, `value`
### `sitestatistic`
- Purpose: Aggregates site request successes, failures, durations, latest state, and diagnostic notes.
- Useful queries: Comparing site availability, failure rate, and the most recent access state.
- Write boundary: Accumulated by site access statistics; never edit counters to conceal runtime behavior.
- Columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`, `note`
### plugindata
Key columns: `id`, `plugin_id`, `key`, `value`
### `siteuserdata`
- Purpose: Stores tracker account level, traffic, ratio, seeding, and unread-message data.
- Useful queries: Inspecting account state, traffic trends, seeding volume, and the latest collection error.
- Write boundary: A site-scraping projection refreshed by synchronization; do not edit it directly.
- Columns: `id`, `domain`, `name`, `username`, `userid`, `user_level`, `join_at`, `bonus`, `upload`, `download`, `ratio`, `seeding`, `leeching`, `seeding_size`, `leeching_size`, `seeding_info`, `message_unread`, `message_unread_contents`, `err_msg`, `updated_day`, `updated_time`
### message
Key columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`
### `subscribe`
- Purpose: Stores active movie, TV, or music subscriptions, filters, progress, and download targets.
- Useful queries: Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress.
- Write boundary: Create, update, search, or delete through the subscription API to preserve state-machine consistency.
- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category`, `filter_groups`, `episode_group`
### workflow
Key columns: `id`, `name`, `description`, `timer`, `trigger_type`, `event_type`, `state`, `run_count`, `actions`, `flows`, `last_time`
### `subscribehistory`
- Purpose: Stores snapshots of completed or archived subscriptions and their final filter state.
- Useful queries: Auditing historical subscriptions, media identity, completion criteria, and filter configuration.
- Write boundary: Generated by subscription completion and archival; restore or delete through its business API.
- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category`, `filter_groups`, `episode_group`
### passkey
Key columns: `id`, `user_id`, `credential_id`, `public_key`, `name`, `created_at`, `last_used_at`, `is_active`
### `systemconfig`
- Purpose: Stores JSON business configuration values keyed by SystemConfigKey.
- Useful queries: Verifying the physical value only when the managed settings API behaves unexpectedly.
- Write boundary: Use config.system.get/update first; direct writes bypass validation, events, and plugin admission.
- Columns: `id`, `key`, `value`
### siteicon
Key columns: `id`, `name`, `domain`, `url`, `base64`
### `transferexecutionstep`
- Purpose: Stores intent, attempt identity, state, and result evidence for each durable transfer operation.
- Useful queries: Diagnosing stuck, failed, or repeated steps by task_id or operation_id.
- Write boundary: Owned by the transfer execution state machine and lease CAS; never force state transitions manually.
- Columns: `id`, `task_id`, `operation_id`, `checkpoint_fingerprint`, `ordinal`, `phase`, `kind`, `state`, `attempt_token`, `attempt_count`, `intent_version`, `intent_payload`, `result_version`, `result_payload`, `last_error`, `prepared_at`, `started_at`, `completed_at`, `updated_at`
### `transferhistory`
- Purpose: Stores transfer source, destination, mode, media identity, download linkage, and outcome.
- Useful queries: Reviewing success/failure history, destination paths, media classification, and download linkage.
- Write boundary: Written by transfer settlement; delete or retry through transfer-history business APIs.
- Columns: `id`, `transfer_task_id`, `transfer_settlement_revision`, `src`, `src_storage`, `src_fileitem`, `dest`, `dest_storage`, `dest_fileitem`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `total_tracks`, `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, `bitrate`, `seasons`, `episodes`, `image`, `downloader`, `download_hash`, `status`, `errmsg`, `date`, `files`, `episode_group`
### `transferpending`
- Purpose: Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state.
- Useful queries: Diagnosing restart recovery, expired leases, retry_wait, terminal failures, or manual review.
- Write boundary: Core durable state machine advanced only by planning, execution, retry, and review services.
- Columns: `id`, `task_id`, `storage`, `src_path`, `created_at`, `state`, `updated_at`, `last_error`, `input_version`, `planning_input`, `input_fingerprint`, `checkpoint_version`, `checkpoint_payload`, `planned_at`, `lease_owner`, `lease_token`, `lease_expires_at`, `heartbeat_at`, `attempt_count`, `execution_state`, `execution_version`, `execution_payload`, `execution_fingerprint`, `retry_generation`, `retry_count`, `retry_due_at`, `retry_requested_by`, `retry_reason`, `settlement_revision`, `terminal_history_id`, `manual_review_revision`, `reviewed_at`, `reviewed_by`, `review_reason`, `review_decision`
### `transfersettlementreceipt`
- Purpose: Stores immutable terminal settlement receipts with contiguous revisions per transfer task.
- Useful queries: Verifying that history, pending deletion, and execution fingerprints were settled reliably.
- Write boundary: Idempotency and audit evidence; append revisions only and never overwrite or delete old receipts.
- Columns: `id`, `task_id`, `history_id`, `settlement_revision`, `outcome`, `execution_fingerprint`, `lease_token`, `history_status`, `src`, `src_storage`, `pending_deleted`, `error`, `created_at`, `updated_at`
### `user`
- Purpose: Stores user accounts, password hashes, administrator state, OTP, permissions, and preferences.
- Useful queries: Authorized diagnostics of account state, permissions, or authentication configuration.
- Write boundary: Security-sensitive; manage through user, permission, password, and two-factor APIs.
- Columns: `id`, `name`, `email`, `hashed_password`, `is_active`, `is_superuser`, `avatar`, `is_otp`, `otp_secret`, `permissions`, `settings`
### `userconfig`
- Purpose: Stores per-user JSON configuration isolated by username and key.
- Useful queries: Inspecting UI preferences, message clear cursors, or other personalized state.
- Write boundary: Modify through the owning user or messaging API to preserve key semantics.
- Columns: `id`, `username`, `key`, `value`
### `workflow`
- Purpose: Stores workflow definitions, triggers, action graphs, execution context, and runtime state.
- Useful queries: Inspecting scheduled/event workflows, pause state, current action, run count, and failures.
- Write boundary: Create, modify, run, pause, or reset through the workflow API.
- Columns: `id`, `name`, `description`, `timer`, `trigger_type`, `event_type`, `event_conditions`, `state`, `current_action`, `result`, `run_count`, `actions`, `flows`, `context`, `execution_config`, `execution_state`, `add_time`, `last_time`
## Database Action Contract
- `tables`: `arguments={}` lists current database tables.
- `schema`: `arguments={"table_name":"downloadhistory"}`; table_name must come from `tables`.
- `query`: `arguments={"sql":"SELECT ...","limit":100,"write":false}`; provide exactly one of sql and file. SELECT/WITH/EXPLAIN are allowed by default.
- `write`: `arguments={"sql":"UPDATE ... WHERE ..."}`; provide exactly one of sql and file and only one statement.
- `file` is a local SQL path readable by the MoviePilot process. MCP clients normally send `sql` directly.
Use the live `schema` result instead of guessing columns from older documentation. Treat `media_source` and `media_id` as one atomic identity pair.
## Common Queries
+111 -9
View File
@@ -1,17 +1,11 @@
#!/usr/bin/env python3
"""
MoviePilot 数据库操作脚本。
脚本从项目配置读取数据库连接参数,不要求 Agent 在提示词中接触数据库密码。
默认只允许查询语句;写操作必须显式传入 --write。
"""
from __future__ import annotations
"""Controlled MoviePilot database helper used by the database-operation Skill."""
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@@ -19,7 +13,6 @@ from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import SQLAlchemyError
SCRIPT_PATH = Path(__file__).resolve()
PROJECT_ROOT = SCRIPT_PATH.parents[3]
WRITE_STATEMENT_RE = re.compile(
@@ -33,6 +26,115 @@ WRITE_KEYWORD_RE = re.compile(
SELECT_STATEMENT_RE = re.compile(r"^\s*(select|with|explain)\b", re.IGNORECASE)
@dataclass(frozen=True, slots=True)
class ArgumentSpec:
"""Describe one public database action argument."""
name: str
type: str
description: str
required: bool = False
default: Any = None
has_default: bool = False
def to_dict(self) -> dict[str, Any]:
"""Return the public argument contract used by the MCP schema generator."""
result = {
"name": self.name,
"type": self.type,
"description": self.description,
"required": self.required,
}
if self.has_default:
result["default"] = self.default
return result
@dataclass(frozen=True, slots=True)
class ActionSpec:
"""Describe one stable database helper action."""
description: str
effect: str
arguments: tuple[ArgumentSpec, ...] = ()
argument_rules: tuple[str, ...] = ()
@property
def required(self) -> tuple[str, ...]:
"""Return required argument names for this action."""
return tuple(argument.name for argument in self.arguments if argument.required)
def to_dict(self, name: str) -> dict[str, Any]:
"""Return the implementation-independent public action contract."""
return {
"action": name,
"description": self.description,
"effect": self.effect,
"providers": ["sqlite", "postgresql"],
"required_arguments": list(self.required),
"arguments": [argument.to_dict() for argument in self.arguments],
"argument_rules": list(self.argument_rules),
}
ACTIONS: dict[str, ActionSpec] = {
"tables": ActionSpec(
"List all tables visible to the configured MoviePilot database.",
"safe_read",
),
"schema": ActionSpec(
"Show columns and nullability for one database table.",
"safe_read",
arguments=(
ArgumentSpec(
"table_name",
"string",
"Exact database table name returned by the tables action.",
required=True,
),
),
),
"query": ActionSpec(
"Run one bounded SQL query, optionally enabling the explicit write mode.",
"safe_read",
arguments=(
ArgumentSpec("sql", "string", "One SQL statement; mutually exclusive with file."),
ArgumentSpec("file", "string", "Local SQL file path; mutually exclusive with sql."),
ArgumentSpec(
"limit",
"integer",
"Maximum row count appended to a plain SELECT that has no LIMIT.",
default=100,
has_default=True,
),
ArgumentSpec(
"write",
"boolean",
"Allow query to execute a write statement; use true only with explicit authorization.",
default=False,
has_default=True,
),
),
argument_rules=(
"Provide exactly one of sql and file.",
"Only SELECT, WITH, and EXPLAIN are allowed unless write=true.",
),
),
"write": ActionSpec(
"Run one explicitly authorized SQL write or schema statement.",
"destructive_write",
arguments=(
ArgumentSpec("sql", "string", "One data-write or schema-change statement; mutually exclusive with file."),
ArgumentSpec("file", "string", "Local SQL file path; mutually exclusive with sql."),
),
argument_rules=(
"Provide exactly one of sql and file.",
"Multiple SQL statements in one call are rejected.",
),
),
}
def _ensure_project_import() -> None:
"""确保脚本可以从任意工作目录导入 MoviePilot 项目模块。"""
project_path = str(PROJECT_ROOT)
+166 -64
View File
@@ -1,6 +1,6 @@
---
name: downloader-operation
version: 2
version: 3
description: >-
Use this skill when the user asks to inspect, diagnose, or directly control a
configured qBittorrent, Transmission, or rTorrent instance. It exposes
@@ -122,82 +122,184 @@ instances remain ambiguous, the result lists the valid client names.
## Complete Action Contract
In the tables below, `*` means required. Every listed field belongs inside the
single `--arguments` JSON object. Do not send fields that are not listed.
This is the complete Downloader Operation action contract. It comes directly from the script `ACTIONS` registry and matches the external MCP `tools/list` oneOf branches.
A field name ending in `*` is required. Put every action parameter in the `arguments` object.
Shared rules:
| action | Purpose and argument summary |
| :--- | :--- |
| `capabilities.list` | List supported downloader actions and their complete argument contracts.; arguments: `action_name` |
| `instances.list` | List configured downloader instances without connection secrets.; no arguments |
| `session.content_layout` | Read qBittorrent's default torrent content layout.; no arguments |
| `session.details` | Read Transmission session configuration and capacity details.; no arguments |
| `session.speed_limits.get` | Read global speed limits.; no arguments |
| `session.speed_limits.set` | Set global speed limits in KB/s.; arguments: `download_limit`, `upload_limit` |
| `session.stats` | Read provider transfer/session statistics.; no arguments |
| `tasks.add.direct` | Submit a magnet, URL, or local torrent file directly to the provider.; arguments: `content*`, `torrent_file`, `paused`, `download_dir`, `tags`, `category` |
| `tasks.category.set` | Set qBittorrent category.; arguments: `task_id*`, `category*` |
| `tasks.delete` | Delete tasks and optionally their data.; arguments: `task_id`, `task_ids`, `delete_files` |
| `tasks.files` | List files and priorities for one task.; arguments: `task_id*`, `offset`, `limit` |
| `tasks.files.selection.set` | Select wanted and unwanted files within one task.; arguments: `task_id*`, `wanted_file_ids`, `unwanted_file_ids` |
| `tasks.force_start.set` | Enable or disable qBittorrent force-start for tasks.; arguments: `task_id`, `task_ids`, `enabled*` |
| `tasks.list` | List and filter downloader tasks.; arguments: `task_id`, `task_ids`, `status`, `tags`, `offset`, `limit` |
| `tasks.location.set` | Move or retarget one task to a provider-side path.; arguments: `task_id*`, `location*` |
| `tasks.peers` | Read qBittorrent peer synchronization data.; arguments: `task_id*` |
| `tasks.properties.set` | Set task speed, ratio, or seeding-time limits.; arguments: `task_id*`, `upload_limit`, `download_limit`, `ratio_limit`, `seeding_time_limit` |
| `tasks.queue.move` | Move tasks to top, up, down, or bottom of the queue.; arguments: `task_id`, `task_ids`, `position*` |
| `tasks.reannounce` | Force tracker reannounce.; arguments: `task_id`, `task_ids` |
| `tasks.recheck` | Force data verification for tasks.; arguments: `task_id`, `task_ids` |
| `tasks.start` | Start or resume one or more tasks.; arguments: `task_id`, `task_ids` |
| `tasks.stop` | Pause one or more tasks.; arguments: `task_id`, `task_ids` |
| `tasks.tags.get` | Read task tags or labels.; arguments: `task_id*` |
| `tasks.tags.set` | Set or add task tags/labels.; arguments: `task_id`, `task_ids`, `tags*` |
| `tasks.trackers` | List trackers for one task.; arguments: `task_id*` |
| `tasks.trackers.update` | Add or replace task trackers.; arguments: `task_id*`, `trackers*` |
- Task batch actions require exactly one of `task_id:string` or
`task_ids:string[]`.
- Paged reads accept `offset:integer=0` and `limit:integer=50`; `offset` must be
non-negative and `limit` is clamped to `1..200`.
- Speed values are numbers in `KB/s`. A value of `0` means unlimited.
- Task IDs, file indexes, tags, tracker URLs, and provider paths must come from
the selected downloader or the user's explicit input; never invent them.
### `capabilities.list`
List supported downloader actions and their complete argument contracts. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `action_name` (string): Optional exact action name used to return one capability contract.
### Task reads
### `instances.list`
List configured downloader instances without connection secrets. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `arguments`: `{}`
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `tasks.list` | List/filter tasks; all | `task_id:string` or `task_ids:string[]`; `status:string`; `tags:string\|string[]`; `offset:integer=0`; `limit:integer=50` |
| `tasks.files` | List files and priorities for one task; all | `task_id*:string`; `offset:integer=0`; `limit:integer=50` |
| `tasks.trackers` | List tracker URLs; qBittorrent, Transmission | `task_id*:string` |
| `tasks.tags.get` | Read tags/labels for one task; all | `task_id*:string` |
| `tasks.peers` | Read peer synchronization data; qBittorrent | `task_id*:string` |
### `session.content_layout`
Read qBittorrent's default torrent content layout. Effect: `safe_read`. Providers: `qbittorrent`.
- `arguments`: `{}`
### Task control
### `session.details`
Read Transmission session configuration and capacity details. Effect: `safe_read`. Providers: `transmission`.
- `arguments`: `{}`
| Action | Function and effect | `--arguments` fields |
|---|---|---|
| `tasks.start` | Start/resume tasks; reversible write | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.stop` | Pause tasks; reversible write | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.recheck` | Force data verification; external side effect | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.reannounce` | Force tracker reannounce; qBittorrent/Transmission, external side effect | exactly one of `task_id:string`, `task_ids:string[]` |
| `tasks.queue.move` | Move queue position; qBittorrent/Transmission, reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `position*:string` = `top\|up\|down\|bottom` |
| `tasks.force_start.set` | Toggle force-start; qBittorrent, reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `enabled*:boolean` |
| `tasks.files.selection.set` | Select files within one task; reversible write | `task_id*:string`; `wanted_file_ids:integer[]`; `unwanted_file_ids:integer[]`; at least one list, with no overlapping index |
| `tasks.properties.set` | Set per-task limits; reversible write | `task_id*:string`; at least one of `upload_limit:number`, `download_limit:number`, `ratio_limit:number`, `seeding_time_limit:integer` minutes. rTorrent supports only speed fields |
| `tasks.location.set` | Move/retarget data to a downloader-side path; external side effect | `task_id*:string`; `location*:string` |
| `tasks.category.set` | Set a non-empty category; qBittorrent, reversible write | `task_id*:string`; `category*:string` |
| `tasks.tags.set` | Set/add tags or labels; reversible write | exactly one of `task_id:string`, `task_ids:string[]`; `tags*:string[]` |
| `tasks.trackers.update` | Add/replace trackers; qBittorrent/Transmission, reversible write | `task_id*:string`; `trackers*:string[]` of URLs |
| `tasks.delete` | Delete tasks and optionally data; destructive write | exactly one of `task_id:string`, `task_ids:string[]`; `delete_files:boolean=false` |
| `tasks.add.direct` | Submit directly to provider, bypassing MoviePilot orchestration; external side effect | `content*:string` magnet/URL/path; `torrent_file:boolean=false`; `paused:boolean=false`; `download_dir:string`; `tags:string[]`; `category:string` (qBittorrent only) |
### `session.speed_limits.get`
Read global speed limits. Effect: `safe_read`. Providers: `qbittorrent, transmission`.
- `arguments`: `{}`
### Session operations
### `session.speed_limits.set`
Set global speed limits in KB/s. Effect: `reversible_write`. Providers: `qbittorrent, transmission`.
- `download_limit` (number): Global download limit in KB/s; 0 or omission means unlimited.
- `upload_limit` (number): Global upload limit in KB/s; 0 or omission means unlimited.
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `session.stats` | Read transfer/session statistics; all | none (`{}`) |
| `session.speed_limits.get` | Read global download/upload limits; qBittorrent, Transmission | none (`{}`) |
| `session.speed_limits.set` | Set global limits; qBittorrent, Transmission | at least one of `download_limit:number`, `upload_limit:number`; use explicit `0` to clear a limit |
| `session.details` | Read Transmission session configuration/capacity; Transmission | none (`{}`) |
| `session.content_layout` | Read default torrent content layout; qBittorrent | none (`{}`) |
### `session.stats`
Read provider transfer/session statistics. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `arguments`: `{}`
Examples:
### `tasks.add.direct`
Submit a magnet, URL, or local torrent file directly to the provider. Effect: `external_side_effect`. Providers: `qbittorrent, transmission, rtorrent`.
- `content*` (string): Magnet URI, torrent URL, or a local torrent path when torrent_file=true.
- `torrent_file` (boolean; default `False`): Interpret content as a local torrent-file path.
- `paused` (boolean; default `False`): Add the task in a paused state.
- `download_dir` (string): Provider-side save path.
- `tags` (string[]): Tags to assign to the new task.
- `category` (string): qBittorrent category; ignored by other providers.
```bash
# Read one task's files.
python skills/downloader-operation/scripts/mp-downloader.py call \
--client "main-qb" \
--action tasks.files \
--arguments '{"task_id":"exact-provider-hash","offset":0,"limit":50}'
### `tasks.category.set`
Set qBittorrent category. Effect: `reversible_write`. Providers: `qbittorrent`.
- `task_id*` (string): One provider-native task hash or ID.
- `category*` (string): Non-empty qBittorrent category name.
# Limit one task to 2048 KB/s download and 512 KB/s upload.
python skills/downloader-operation/scripts/mp-downloader.py call \
--client "main-qb" \
--action tasks.properties.set \
--arguments '{"task_id":"exact-provider-hash","download_limit":2048,"upload_limit":512}'
```
### `tasks.delete`
Delete tasks and optionally their data. Effect: `destructive_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- `delete_files` (boolean; default `False`): Also permanently delete the task data files.
- Rule: Provide exactly one of task_id and task_ids.
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.
### `tasks.files`
List files and priorities for one task. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id*` (string): One provider-native task hash or ID.
- `offset` (integer; default `0`): Zero-based list offset.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
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.
### `tasks.files.selection.set`
Select wanted and unwanted files within one task. Effect: `reversible_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id*` (string): One provider-native task hash or ID.
- `wanted_file_ids` (integer[]): Provider file indexes to download; provide this or unwanted_file_ids.
- `unwanted_file_ids` (integer[]): Provider file indexes to skip; provide this or wanted_file_ids.
- Rule: Provide wanted_file_ids or unwanted_file_ids, and never place one index in both lists.
### `tasks.force_start.set`
Enable or disable qBittorrent force-start for tasks. Effect: `reversible_write`. Providers: `qbittorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- `enabled*` (boolean): Whether force-start is enabled.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.list`
List and filter downloader tasks. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- `status` (string): Filter by the provider-native task status.
- `tags` (string|string[]): Return only tasks that contain all specified tags.
- `offset` (integer; default `0`): Zero-based list offset.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
### `tasks.location.set`
Move or retarget one task to a provider-side path. Effect: `external_side_effect`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id*` (string): One provider-native task hash or ID.
- `location*` (string): New provider-side save path.
### `tasks.peers`
Read qBittorrent peer synchronization data. Effect: `safe_read`. Providers: `qbittorrent`.
- `task_id*` (string): One provider-native task hash or ID.
### `tasks.properties.set`
Set task speed, ratio, or seeding-time limits. Effect: `reversible_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id*` (string): One provider-native task hash or ID.
- `upload_limit` (number): Upload limit in KB/s; 0 means unlimited.
- `download_limit` (number): Download limit in KB/s; 0 means unlimited.
- `ratio_limit` (number): Share-ratio limit; unsupported by rTorrent.
- `seeding_time_limit` (integer): Seeding-time limit in minutes; unsupported by rTorrent.
### `tasks.queue.move`
Move tasks to top, up, down, or bottom of the queue. Effect: `reversible_write`. Providers: `qbittorrent, transmission`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- `position*` (string; allowed values `top,up,down,bottom`): Target queue position.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.reannounce`
Force tracker reannounce. Effect: `external_side_effect`. Providers: `qbittorrent, transmission`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.recheck`
Force data verification for tasks. Effect: `external_side_effect`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.start`
Start or resume one or more tasks. Effect: `reversible_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.stop`
Pause one or more tasks. Effect: `reversible_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.tags.get`
Read task tags or labels. Effect: `safe_read`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id*` (string): One provider-native task hash or ID.
### `tasks.tags.set`
Set or add task tags/labels. Effect: `reversible_write`. Providers: `qbittorrent, transmission, rtorrent`.
- `task_id` (string): One provider-native task hash or ID.
- `task_ids` (string[]): Multiple provider-native task hashes or IDs; mutually exclusive with task_id.
- `tags*` (string[]): Tags or labels to set or add.
- Rule: Provide exactly one of task_id and task_ids.
### `tasks.trackers`
List trackers for one task. Effect: `safe_read`. Providers: `qbittorrent, transmission`.
- `task_id*` (string): One provider-native task hash or ID.
### `tasks.trackers.update`
Add or replace task trackers. Effect: `reversible_write`. Providers: `qbittorrent, transmission`.
- `task_id*` (string): One provider-native task hash or ID.
- `trackers*` (string[]): Tracker URL list.
## Verification
@@ -83,21 +83,40 @@ class ActionSpec:
}
TASK_ID = ArgumentSpec("task_id", "string", "单个任务的 provider 原生 hash ID")
TASK_IDS = ArgumentSpec("task_ids", "string[]", "多个任务的 provider 原生 hash 或 ID;与 task_id 二选一。")
OFFSET = ArgumentSpec("offset", "integer", "列表起始偏移,必须大于等于 0。", default=0)
LIMIT = ArgumentSpec("limit", "integer", "返回条数,范围 1..200。", default=DEFAULT_LIMIT)
TASK_ID = ArgumentSpec("task_id", "string", "One provider-native task hash or ID.")
TASK_IDS = ArgumentSpec(
"task_ids",
"string[]",
"Multiple provider-native task hashes or IDs; mutually exclusive with task_id.",
)
OFFSET = ArgumentSpec("offset", "integer", "Zero-based list offset.", default=0)
LIMIT = ArgumentSpec("limit", "integer", "Number of items to return, from 1 to 200.", default=DEFAULT_LIMIT)
ACTIONS: dict[str, ActionSpec] = {
"instances.list": ActionSpec(
"List configured downloader instances without connection secrets.",
"safe_read",
),
"capabilities.list": ActionSpec(
"List supported downloader actions and their complete argument contracts.",
"safe_read",
arguments=(
ArgumentSpec(
"action_name",
"string",
"Optional exact action name used to return one capability contract.",
),
),
),
"tasks.list": ActionSpec(
"List and filter downloader tasks.",
"safe_read",
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("status", "string", "按 provider 原生任务状态过滤。"),
ArgumentSpec("tags", "string|string[]", "只返回同时包含这些标签的任务。"),
ArgumentSpec("status", "string", "Filter by the provider-native task status."),
ArgumentSpec("tags", "string|string[]", "Return only tasks that contain all specified tags."),
OFFSET,
LIMIT,
),
@@ -112,10 +131,12 @@ ACTIONS: dict[str, ActionSpec] = {
"reversible_write",
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("wanted_file_ids", "integer[]", "要下载的 provider 文件索引;与 unwanted_file_ids 至少提供一项。"),
ArgumentSpec("unwanted_file_ids", "integer[]", "跳过的 provider 文件索引;与 wanted_file_ids 至少提供一项。"),
ArgumentSpec("wanted_file_ids", "integer[]", "Provider file indexes to download; provide this or unwanted_file_ids."),
ArgumentSpec("unwanted_file_ids", "integer[]", "Provider file indexes to skip; provide this or wanted_file_ids."),
),
argument_rules=(
"Provide wanted_file_ids or unwanted_file_ids, and never place one index in both lists.",
),
argument_rules=("wanted_file_ids 与 unwanted_file_ids 至少提供一项,且同一索引不能同时出现。",),
),
"tasks.trackers": ActionSpec(
"List trackers for one task.",
@@ -138,13 +159,13 @@ ACTIONS: dict[str, ActionSpec] = {
"Start or resume one or more tasks.",
"reversible_write",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id task_ids 必须提供且只能选择一种。",),
argument_rules=("Provide exactly one of task_id and task_ids.",),
),
"tasks.stop": ActionSpec(
"Pause one or more tasks.",
"reversible_write",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id task_ids 必须提供且只能选择一种。",),
argument_rules=("Provide exactly one of task_id and task_ids.",),
),
"tasks.delete": ActionSpec(
"Delete tasks and optionally their data.",
@@ -152,22 +173,22 @@ ACTIONS: dict[str, ActionSpec] = {
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("delete_files", "boolean", "同时永久删除任务数据文件。", default=False),
ArgumentSpec("delete_files", "boolean", "Also permanently delete the task data files.", default=False),
),
argument_rules=("task_id task_ids 必须提供且只能选择一种。",),
argument_rules=("Provide exactly one of task_id and task_ids.",),
),
"tasks.recheck": ActionSpec(
"Force data verification for tasks.",
"external_side_effect",
arguments=(TASK_ID, TASK_IDS),
argument_rules=("task_id task_ids 必须提供且只能选择一种。",),
argument_rules=("Provide exactly one of task_id and task_ids.",),
),
"tasks.reannounce": ActionSpec(
"Force tracker reannounce.",
"external_side_effect",
("qbittorrent", "transmission"),
(TASK_ID, TASK_IDS),
("task_id task_ids 必须提供且只能选择一种。",),
("Provide exactly one of task_id and task_ids.",),
),
"tasks.queue.move": ActionSpec(
"Move tasks to top, up, down, or bottom of the queue.",
@@ -179,12 +200,12 @@ ACTIONS: dict[str, ActionSpec] = {
ArgumentSpec(
"position",
"string",
"目标队列位置。",
"Target queue position.",
required=True,
enum=("top", "up", "down", "bottom"),
),
),
("task_id task_ids 必须提供且只能选择一种。",),
("Provide exactly one of task_id and task_ids.",),
),
"tasks.force_start.set": ActionSpec(
"Enable or disable qBittorrent force-start for tasks.",
@@ -193,19 +214,19 @@ ACTIONS: dict[str, ActionSpec] = {
(
TASK_ID,
TASK_IDS,
ArgumentSpec("enabled", "boolean", "是否启用强制开始。", required=True),
ArgumentSpec("enabled", "boolean", "Whether force-start is enabled.", required=True),
),
("task_id task_ids 必须提供且只能选择一种。",),
("Provide exactly one of task_id and task_ids.",),
),
"tasks.properties.set": ActionSpec(
"Set task speed, ratio, or seeding-time limits.",
"reversible_write",
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("upload_limit", "number", "上传限速,单位 KB/s;0 表示不限速。"),
ArgumentSpec("download_limit", "number", "下载限速,单位 KB/s;0 表示不限速。"),
ArgumentSpec("ratio_limit", "number", "分享率上限;rTorrent 不支持。"),
ArgumentSpec("seeding_time_limit", "integer", "做种时间上限,单位分钟;rTorrent 不支持。"),
ArgumentSpec("upload_limit", "number", "Upload limit in KB/s; 0 means unlimited."),
ArgumentSpec("download_limit", "number", "Download limit in KB/s; 0 means unlimited."),
ArgumentSpec("ratio_limit", "number", "Share-ratio limit; unsupported by rTorrent."),
ArgumentSpec("seeding_time_limit", "integer", "Seeding-time limit in minutes; unsupported by rTorrent."),
),
),
"tasks.location.set": ActionSpec(
@@ -213,7 +234,7 @@ ACTIONS: dict[str, ActionSpec] = {
"external_side_effect",
arguments=(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("location", "string", "下载器侧的新保存路径。", required=True),
ArgumentSpec("location", "string", "New provider-side save path.", required=True),
),
),
"tasks.category.set": ActionSpec(
@@ -222,7 +243,7 @@ ACTIONS: dict[str, ActionSpec] = {
("qbittorrent",),
(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("category", "string", "非空分类名称。", required=True),
ArgumentSpec("category", "string", "Non-empty qBittorrent category name.", required=True),
),
),
"tasks.tags.set": ActionSpec(
@@ -231,9 +252,9 @@ ACTIONS: dict[str, ActionSpec] = {
arguments=(
TASK_ID,
TASK_IDS,
ArgumentSpec("tags", "string[]", "要设置或添加的标签列表。", required=True),
ArgumentSpec("tags", "string[]", "Tags or labels to set or add.", required=True),
),
argument_rules=("task_id task_ids 必须提供且只能选择一种。",),
argument_rules=("Provide exactly one of task_id and task_ids.",),
),
"tasks.trackers.update": ActionSpec(
"Add or replace task trackers.",
@@ -241,19 +262,19 @@ ACTIONS: dict[str, ActionSpec] = {
("qbittorrent", "transmission"),
(
ArgumentSpec("task_id", "string", TASK_ID.description, required=True),
ArgumentSpec("trackers", "string[]", "Tracker URL 列表。", required=True),
ArgumentSpec("trackers", "string[]", "Tracker URL list.", required=True),
),
),
"tasks.add.direct": ActionSpec(
"Submit a magnet, URL, or local torrent file directly to the provider.",
"external_side_effect",
arguments=(
ArgumentSpec("content", "string", "Magnettorrent URL,或 torrent_file=true 时的本地种子文件路径。", required=True),
ArgumentSpec("torrent_file", "boolean", "将 content 解释为本地种子文件路径。", default=False),
ArgumentSpec("paused", "boolean", "以暂停状态添加任务。", default=False),
ArgumentSpec("download_dir", "string", "下载器侧保存路径。"),
ArgumentSpec("tags", "string[]", "添加到任务的标签。"),
ArgumentSpec("category", "string", "qBittorrent 分类;其他 provider 忽略。"),
ArgumentSpec("content", "string", "Magnet URI, torrent URL, or a local torrent path when torrent_file=true.", required=True),
ArgumentSpec("torrent_file", "boolean", "Interpret content as a local torrent-file path.", default=False),
ArgumentSpec("paused", "boolean", "Add the task in a paused state.", default=False),
ArgumentSpec("download_dir", "string", "Provider-side save path."),
ArgumentSpec("tags", "string[]", "Tags to assign to the new task."),
ArgumentSpec("category", "string", "qBittorrent category; ignored by other providers."),
),
),
"session.stats": ActionSpec("Read provider transfer/session statistics.", "safe_read"),
@@ -263,8 +284,8 @@ ACTIONS: dict[str, ActionSpec] = {
"reversible_write",
("qbittorrent", "transmission"),
(
ArgumentSpec("download_limit", "number", "全局下载限速,单位 KB/s;0 或省略表示不限速。"),
ArgumentSpec("upload_limit", "number", "全局上传限速,单位 KB/s;0 或省略表示不限速。"),
ArgumentSpec("download_limit", "number", "Global download limit in KB/s; 0 or omission means unlimited."),
ArgumentSpec("upload_limit", "number", "Global upload limit in KB/s; 0 or omission means unlimited."),
),
),
"session.details": ActionSpec(
@@ -758,6 +779,25 @@ def call_action(client_name: Optional[str], action: str, arguments: Mapping[str,
if spec is None:
raise ValueError(f"未知 downloader action: {action}")
_validate_action_arguments(action, spec, arguments)
if action == "instances.list":
return {
"success": True,
"client": None,
"provider": None,
"action": action,
"effect": spec.effect,
"data": list_instances()["instances"],
}
if action == "capabilities.list":
capabilities = list_capabilities(client_name, arguments.get("action_name"))
return {
"success": True,
"client": capabilities["client"],
"provider": capabilities["provider"],
"action": action,
"effect": spec.effect,
"data": capabilities["actions"],
}
config = _select_config(client_name)
provider = str(config.type or "").lower()
if provider not in spec.providers:
+103 -65
View File
@@ -1,6 +1,6 @@
---
name: mediaserver-operation
version: 2
version: 3
description: >-
Use this skill when the user asks to inspect, diagnose, or directly operate a
configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, or Navidrome
@@ -113,84 +113,122 @@ instances remain ambiguous, the result lists the valid server names.
## Complete Action Contract
In the tables below, `*` means required. Every listed field belongs inside the
single `--arguments` JSON object. Do not send fields that are not listed.
This is the complete Media Server Operation action contract. It comes directly from the script `ACTIONS` registry and matches the external MCP `tools/list` oneOf branches.
A field name ending in `*` is required. Put every action parameter in the `arguments` object.
Shared rules:
| action | Purpose and argument summary |
| :--- | :--- |
| `activity.backdrops` | Read recent provider backdrop images.; arguments: `limit`, `remote` |
| `activity.latest` | Read recently added provider items.; arguments: `limit`, `username` |
| `activity.resume` | Read in-progress/resumable provider items.; arguments: `limit`, `username` |
| `capabilities.list` | List supported media-server actions and their complete argument contracts.; arguments: `action_name` |
| `instances.list` | List configured media-server instances without connection secrets.; no arguments |
| `items.count` | Count items below one library or parent.; arguments: `parent` |
| `items.detail` | Read one provider item by native ID.; arguments: `item_id*` |
| `items.list` | Page items below one library or parent.; arguments: `parent`, `offset`, `limit` |
| `items.movies.search` | Search provider-native movie items by title and optional year.; arguments: `title*`, `year` |
| `items.music.search` | Search provider-native music by title, artist, or album.; arguments: `title`, `artist`, `album` |
| `items.season_episodes` | Read native episode coverage for one series and optional season.; arguments: `item_id`, `title`, `year`, `season` |
| `libraries.list` | List visible provider libraries.; arguments: `hidden`, `username` |
| `library.scan` | Trigger a provider library scan.; arguments: `scan_mode` |
| `metadata.refresh` | Refresh provider metadata for mapped items.; arguments: `items*` |
| `playback.sessions` | Read active playback sessions.; no arguments |
| `playback.url` | Build the provider play URL for one item.; arguments: `item_id*` |
| `server.statistics` | Read media counts and provider statistics.; no arguments |
| `server.user.library_folders` | Read the current user's visible library folders.; no arguments |
| `server.users.count` | Read provider user count.; no arguments |
- Paged reads accept `offset:integer=0` where documented and
`limit:integer=50`; `offset` must be non-negative and `limit` is clamped to
`1..200`.
- `parent`, `item_id`, library IDs, and usernames are native to the selected
server. Obtain them from that server's earlier response; never reuse IDs from
another instance.
- All actions support only the providers shown by `capabilities`. The provider
list below lets the Agent choose without inspecting source; query the selected
instance only when provider support must be confirmed.
### `activity.backdrops`
Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia`.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- `remote` (boolean; default `False`): Return provider URLs that are remotely accessible.
Provider abbreviations used below: all = Emby, Jellyfin, Plex, ZSpace, UGREEN,
TrimeMedia, and Navidrome.
### `activity.latest`
Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
### Server and library reads
### `activity.resume`
Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `server.statistics` | Read media counts/provider statistics; all | none (`{}`) |
| `server.users.count` | Read provider user count; Emby, Jellyfin, ZSpace, UGREEN, TrimeMedia, Navidrome | none (`{}`) |
| `server.user.library_folders` | Read current user's visible folders; Emby, Jellyfin, ZSpace | none (`{}`) |
| `libraries.list` | List visible libraries; all | `hidden:boolean=false` (true = configured sync scope only); `username:string` only for Emby/Jellyfin/ZSpace |
| `items.list` | Page items below a library/parent; all | `parent:string\|integer` required except Navidrome; `offset:integer=0`; `limit:integer=50` |
| `items.count` | Count items below a library/parent; all | `parent:string\|integer` required except Navidrome; omitted on Navidrome uses `music` |
| `items.detail` | Read one provider item; all | `item_id*:string` |
### `capabilities.list`
List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `action_name` (string): Optional exact action name used to return one capability contract.
### Native search and activity
### `instances.list`
List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `arguments`: `{}`
| Action | Function and providers | `--arguments` fields |
|---|---|---|
| `items.movies.search` | Search movies; Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia | `title*:string`; `year:string\|integer` |
| `items.music.search` | Search music; Emby, Jellyfin, Plex, ZSpace, UGREEN, Navidrome | `title:string`; `artist:string`; `album:string`; at least one is required |
| `items.season_episodes` | Read existing episode coverage for a series; Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia | `item_id:string`; `title:string`; at least one is required; optional `year:string\|integer`; `season:integer` |
| `activity.latest` | Read recently added items; all | `limit:integer=50`; `username:string` only for Emby/Jellyfin/ZSpace |
| `activity.resume` | Read in-progress/resumable items; all | `limit:integer=50`; `username:string` only for Emby/Jellyfin/ZSpace |
| `activity.backdrops` | Read recent backdrop URLs; UGREEN, TrimeMedia | `limit:integer=50`; `remote:boolean=false` |
### `items.count`
Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
- Rule: parent is required except for Navidrome, which defaults to music.
### Playback and writes
### `items.detail`
Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `item_id*` (string): Provider-native item ID returned by the selected media server.
| Action | Function, providers, and effect | `--arguments` fields |
|---|---|---|
| `playback.sessions` | Read active sessions; Emby, Jellyfin, Plex; safe read | none (`{}`) |
| `playback.url` | Build provider play URL; all; safe read | `item_id*:string` |
| `library.scan` | Trigger provider root-library scan; all; external side effect | `scan_mode:string\|integer` only for UGREEN; otherwise omit |
| `metadata.refresh` | Refresh metadata for mapped items; Emby, Plex, ZSpace, UGREEN, TrimeMedia; external side effect | `items*:object[]`; each object supports `title:string`, `year:string\|integer`, `type:string` (`电影\|电视剧\|音乐`), `category:string`, `target_path:string` |
### `items.list`
Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
- `offset` (integer; default `0`): Zero-based list offset.
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
- Rule: parent is required except for Navidrome, which ignores it.
Examples:
### `items.movies.search`
Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
- `title*` (string): Movie title.
- `year` (string|integer): Optional release year.
```bash
# List the first page below an exact library ID.
python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
--server "living-room" \
--action items.list \
--arguments '{"parent":"exact-library-id","offset":0,"limit":50}'
### `items.music.search`
Search provider-native music by title, artist, or album. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, navidrome`.
- `title` (string): Track, album, or music-item title.
- `artist` (string): Artist name.
- `album` (string): Album name; provide title, artist, or album.
- Rule: Provide at least one of title, artist, and album.
# Read season 2 coverage using an exact provider series ID.
python skills/mediaserver-operation/scripts/mp-mediaserver.py call \
--server "living-room" \
--action items.season_episodes \
--arguments '{"item_id":"exact-series-id","season":2}'
```
### `items.season_episodes`
Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
- `item_id` (string): Provider-native item ID returned by the selected media server.
- `title` (string): Series title; provide it or item_id.
- `year` (string|integer): Optional premiere year.
- `season` (integer): Optional season number.
- Rule: Provide at least one of item_id and title.
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`).
### `libraries.list`
List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `hidden` (boolean; default `False`): Return only libraries configured for synchronization.
- `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace.
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.
### `library.scan`
Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `scan_mode` (string|integer): UGREEN-native scan mode; omit it for every other provider.
### `metadata.refresh`
Refresh provider metadata for mapped items. Effect: `external_side_effect`. Providers: `emby, plex, zspace, ugreen, trimemedia`.
- `items*` (object[]): Items to refresh. Each item supports title:string, year:string|integer, type using the exact MoviePilot media-type value, category:string, and target_path:string.
### `playback.sessions`
Read active playback sessions. Effect: `safe_read`. Providers: `emby, jellyfin, plex`.
- `arguments`: `{}`
### `playback.url`
Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `item_id*` (string): Provider-native item ID returned by the selected media server.
### `server.statistics`
Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
- `arguments`: `{}`
### `server.user.library_folders`
Read the current user's visible library folders. Effect: `safe_read`. Providers: `emby, jellyfin, zspace`.
- `arguments`: `{}`
### `server.users.count`
Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome`.
- `arguments`: `{}`
## Safety And Verification
@@ -96,13 +96,28 @@ class ActionSpec:
}
ITEM_ID = ArgumentSpec("item_id", "string", "当前媒体服务器返回的 provider 原生条目 ID。")
PARENT = ArgumentSpec("parent", "string|integer", "媒体库或父条目 IDNavidrome 可省略并使用 music")
OFFSET = ArgumentSpec("offset", "integer", "列表起始偏移,必须大于等于 0。", default=0)
LIMIT = ArgumentSpec("limit", "integer", "返回条数,范围 1..200", default=DEFAULT_LIMIT)
ITEM_ID = ArgumentSpec("item_id", "string", "Provider-native item ID returned by the selected media server.")
PARENT = ArgumentSpec("parent", "string|integer", "Library or parent item ID; Navidrome may omit it and use music.")
OFFSET = ArgumentSpec("offset", "integer", "Zero-based list offset.", default=0)
LIMIT = ArgumentSpec("limit", "integer", "Number of items to return, from 1 to 200.", default=DEFAULT_LIMIT)
ACTIONS: dict[str, ActionSpec] = {
"instances.list": ActionSpec(
"List configured media-server instances without connection secrets.",
"safe_read",
),
"capabilities.list": ActionSpec(
"List supported media-server actions and their complete argument contracts.",
"safe_read",
arguments=(
ArgumentSpec(
"action_name",
"string",
"Optional exact action name used to return one capability contract.",
),
),
),
"server.statistics": ActionSpec("Read media counts and provider statistics.", "safe_read"),
"server.users.count": ActionSpec(
"Read provider user count.",
@@ -118,21 +133,21 @@ ACTIONS: dict[str, ActionSpec] = {
"List visible provider libraries.",
"safe_read",
arguments=(
ArgumentSpec("hidden", "boolean", "仅返回配置为同步范围的媒体库。", default=False),
ArgumentSpec("username", "string", "按用户名读取可见媒体库;仅 EmbyJellyfinZSpace 支持。"),
ArgumentSpec("hidden", "boolean", "Return only libraries configured for synchronization.", default=False),
ArgumentSpec("username", "string", "Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace."),
),
),
"items.list": ActionSpec(
"Page items below one library or parent.",
"safe_read",
arguments=(PARENT, OFFSET, LIMIT),
argument_rules=("除 Navidrome 外必须提供 parentNavidrome 忽略 parent。",),
argument_rules=("parent is required except for Navidrome, which ignores it.",),
),
"items.count": ActionSpec(
"Count items below one library or parent.",
"safe_read",
arguments=(PARENT,),
argument_rules=("除 Navidrome 外必须提供 parentNavidrome 省略时使用 music",),
argument_rules=("parent is required except for Navidrome, which defaults to music.",),
),
"items.detail": ActionSpec(
"Read one provider item by native ID.",
@@ -144,8 +159,8 @@ ACTIONS: dict[str, ActionSpec] = {
"safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
(
ArgumentSpec("title", "string", "电影标题。", required=True),
ArgumentSpec("year", "string|integer", "可选发行年份。"),
ArgumentSpec("title", "string", "Movie title.", required=True),
ArgumentSpec("year", "string|integer", "Optional release year."),
),
),
"items.music.search": ActionSpec(
@@ -153,11 +168,11 @@ ACTIONS: dict[str, ActionSpec] = {
"safe_read",
("emby", "jellyfin", "plex", "zspace", "ugreen", "navidrome"),
(
ArgumentSpec("title", "string", "歌曲、专辑或音乐条目标题。"),
ArgumentSpec("artist", "string", "艺人名称。"),
ArgumentSpec("album", "string", "专辑名称;titleartist、album 至少提供一项。"),
ArgumentSpec("title", "string", "Track, album, or music-item title."),
ArgumentSpec("artist", "string", "Artist name."),
ArgumentSpec("album", "string", "Album name; provide title, artist, or album."),
),
("titleartist、album 至少提供一项。",),
("Provide at least one of title, artist, and album.",),
),
"items.season_episodes": ActionSpec(
"Read native episode coverage for one series and optional season.",
@@ -165,21 +180,21 @@ ACTIONS: dict[str, ActionSpec] = {
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
(
ITEM_ID,
ArgumentSpec("title", "string", "剧集标题;与 item_id 至少提供一项。"),
ArgumentSpec("year", "string|integer", "可选首播年份。"),
ArgumentSpec("season", "integer", "可选季号。"),
ArgumentSpec("title", "string", "Series title; provide it or item_id."),
ArgumentSpec("year", "string|integer", "Optional premiere year."),
ArgumentSpec("season", "integer", "Optional season number."),
),
("item_id title 至少提供一项。",),
("Provide at least one of item_id and title.",),
),
"activity.latest": ActionSpec(
"Read recently added provider items.",
"safe_read",
arguments=(LIMIT, ArgumentSpec("username", "string", "按用户名读取;仅 EmbyJellyfinZSpace 支持。")),
arguments=(LIMIT, ArgumentSpec("username", "string", "Read for this username; supported by Emby, Jellyfin, and ZSpace.")),
),
"activity.resume": ActionSpec(
"Read in-progress/resumable provider items.",
"safe_read",
arguments=(LIMIT, ArgumentSpec("username", "string", "按用户名读取;仅 EmbyJellyfinZSpace 支持。")),
arguments=(LIMIT, ArgumentSpec("username", "string", "Read for this username; supported by Emby, Jellyfin, and ZSpace.")),
),
"activity.backdrops": ActionSpec(
"Read recent provider backdrop images.",
@@ -187,7 +202,7 @@ ACTIONS: dict[str, ActionSpec] = {
("ugreen", "trimemedia"),
(
LIMIT,
ArgumentSpec("remote", "boolean", "返回 provider 可远程访问的图片地址。", default=False),
ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False),
),
),
"playback.sessions": ActionSpec("Read active playback sessions.", "safe_read", ("emby", "jellyfin", "plex")),
@@ -200,7 +215,7 @@ ACTIONS: dict[str, ActionSpec] = {
"Trigger a provider library scan.",
"external_side_effect",
arguments=(
ArgumentSpec("scan_mode", "string|integer", "UGREEN 原生扫描模式;其他 provider 必须省略。"),
ArgumentSpec("scan_mode", "string|integer", "UGREEN-native scan mode; omit it for every other provider."),
),
),
"metadata.refresh": ActionSpec(
@@ -211,7 +226,7 @@ ACTIONS: dict[str, ActionSpec] = {
ArgumentSpec(
"items",
"object[]",
"刷新条目;每项支持 title:stringyear:string|integer、type:电影|电视剧|音乐、category:stringtarget_path:string",
"Items to refresh. Each item supports title:string, year:string|integer, type using the exact MoviePilot media-type value, category:string, and target_path:string.",
required=True,
),
),
@@ -615,6 +630,25 @@ def call_action(server_name: Optional[str], action: str, arguments: Mapping[str,
if spec is None:
raise ValueError(f"未知 media server action: {action}")
_validate_action_arguments(action, spec, arguments)
if action == "instances.list":
return {
"success": True,
"server": None,
"provider": None,
"action": action,
"effect": spec.effect,
"data": list_instances()["instances"],
}
if action == "capabilities.list":
capabilities = list_capabilities(server_name, arguments.get("action_name"))
return {
"success": True,
"server": capabilities["server"],
"provider": capabilities["provider"],
"action": action,
"effect": spec.effect,
"data": capabilities["actions"],
}
config = _select_config(server_name)
provider = str(config.type or "").lower()
if provider not in spec.providers:
File diff suppressed because it is too large Load Diff
-371
View File
@@ -1,371 +0,0 @@
#!/usr/bin/env python3
"""
MoviePilot REST API CLI -- a lightweight command-line client for calling
any MoviePilot API endpoint directly.
Usage:
python mp-api.py configure --host <HOST> --apikey <KEY>
python mp-api.py GET /api/v1/media/search title="Avatar" type="movie"
python mp-api.py POST /api/v1/download/add --json '{"torrent_url":"..."}'
python mp-api.py DELETE /api/v1/subscribe/123
Authentication:
The script sends the API key via the ``X-API-KEY`` header.
It can also fall back to ``?token=`` for endpoints that require it.
Configuration priority:
CLI flags > Environment variables > local MoviePilot settings > Config file
Config file location: ~/.config/moviepilot_api/config
"""
from __future__ import annotations
import json
import os
import ssl
import stat
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
SCRIPT_NAME = os.path.basename(sys.argv[0]) if sys.argv else "mp-api.py"
SCRIPT_PATH = Path(__file__).resolve()
PROJECT_ROOT = SCRIPT_PATH.parents[3]
CONFIG_DIR = Path.home() / ".config" / "moviepilot_api"
CONFIG_FILE = CONFIG_DIR / "config"
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
def read_config() -> tuple[str, str]:
"""Return (host, apikey) from the config file."""
host = ""
apikey = ""
if not CONFIG_FILE.exists():
return host, apikey
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key == "MP_HOST":
host = value
elif key == "MP_API_KEY":
apikey = value
return host, apikey
def save_config(host: str, apikey: str) -> None:
"""Persist host and API key to the legacy config file."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(f"MP_HOST={host}\nMP_API_KEY={apikey}\n", encoding="utf-8")
CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
def _ensure_project_import() -> None:
"""Add the MoviePilot project root to sys.path for local auto-configuration."""
project_path = str(PROJECT_ROOT)
if project_path not in sys.path:
sys.path.insert(0, project_path)
def _client_host(host: str) -> str:
"""Return a loopback host usable by local clients."""
host = (host or "").strip()
if host in LOCAL_HOSTS:
return "127.0.0.1"
return host
def read_local_config() -> tuple[str, str]:
"""Return host and key from local MoviePilot settings when available."""
try:
_ensure_project_import()
from app.runtime.config import settings # pylint: disable=import-outside-toplevel
except Exception:
return "", ""
host = str(settings.HOST or "")
port = settings.PORT
apikey = str(settings.API_TOKEN or "")
if host and port:
return f"http://{_client_host(host)}:{port}", apikey
return "", apikey
def resolve_config(
cli_host: str = "",
cli_key: str = "",
) -> tuple[str, str]:
"""Resolve effective host and key without requiring prompt-visible secrets."""
local_host, local_key = read_local_config()
cfg_host, cfg_key = read_config()
host = cli_host or os.environ.get("MP_HOST", "") or local_host or cfg_host
apikey = cli_key or os.environ.get("MP_API_KEY", "") or local_key or cfg_key
return host, apikey
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
# Allow self-signed certs (common in home-lab setups)
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
def http_request(
method: str,
url: str,
headers: dict[str, str] | None = None,
body: bytes | None = None,
timeout: int = 120,
) -> tuple[int, str]:
"""Perform an HTTP request and return (status_code, response_body)."""
headers = headers or {}
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except urllib.error.URLError as exc:
return 0, f"Connection error: {exc.reason}"
def build_url(host: str, path: str, query_params: dict[str, str] | None = None) -> str:
"""Build a full URL from host + path + optional query parameters."""
base = host.rstrip("/")
if not path.startswith("/"):
path = "/" + path
url = base + path
if query_params:
url += "?" + urllib.parse.urlencode(query_params)
return url
# ---------------------------------------------------------------------------
# Core API call
# ---------------------------------------------------------------------------
def api_call(
host: str,
apikey: str,
method: str,
path: str,
query_params: dict[str, str] | None = None,
json_body: object | None = None,
use_token_param: bool = False,
timeout: int = 120,
) -> tuple[int, object]:
"""
Call a MoviePilot REST API endpoint.
Parameters
----------
host : str
MoviePilot base URL (e.g. ``http://localhost:3000``).
apikey : str
The API key (``settings.API_TOKEN`` value).
method : str
HTTP method: GET, POST, PUT, DELETE.
path : str
API path (e.g. ``/api/v1/media/search``).
query_params : dict, optional
Additional query-string parameters.
json_body : object, optional
A JSON-serialisable body for POST/PUT requests.
use_token_param : bool
If True, send the key as ``?token=`` instead of the header.
timeout : int
Request timeout in seconds.
Returns
-------
(status_code, parsed_json_or_text)
"""
headers: dict[str, str] = {}
qp = dict(query_params or {})
if use_token_param:
qp["token"] = apikey
else:
headers["X-API-KEY"] = apikey
body_bytes: bytes | None = None
if json_body is not None:
headers["Content-Type"] = "application/json"
body_bytes = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
url = build_url(host, path, qp if qp else None)
status, raw = http_request(method, url, headers, body_bytes, timeout)
# Try to parse JSON
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
data = raw
return status, data
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def print_json(obj: object) -> None:
"""Pretty-print a JSON-serialisable object to stdout."""
if isinstance(obj, str):
print(obj)
else:
print(json.dumps(obj, indent=2, ensure_ascii=False))
def print_usage() -> None:
print(f"""Usage: python {SCRIPT_NAME} [options] <METHOD> <PATH> [key=value ...] [--json '<body>']
python {SCRIPT_NAME} configure --host <HOST> --apikey <KEY> # legacy fallback
Options:
--host HOST MoviePilot backend URL (auto-read locally when omitted)
--apikey KEY API key (auto-read locally when omitted)
--token-param Send key as ?token= query param instead of X-API-KEY header
--timeout SECS Request timeout (default: 120)
--help Show this help message
Methods: GET POST PUT DELETE
Examples:
python {SCRIPT_NAME} GET /api/v1/media/search title="Avatar" type="movie"
python {SCRIPT_NAME} GET /api/v1/subscribe/
python {SCRIPT_NAME} POST /api/v1/download/add --json '{{"torrent_url":"abc:1"}}'
python {SCRIPT_NAME} DELETE /api/v1/subscribe/123
python {SCRIPT_NAME} GET /api/v1/dashboard/statistic2 --token-param
""")
def main() -> None:
argv = sys.argv[1:]
if not argv or "--help" in argv or "-h" in argv:
print_usage()
sys.exit(0)
# Parse options
cli_host = ""
cli_key = ""
use_token_param = False
timeout = 120
positional: list[str] = []
json_body_str: str | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--host":
i += 1
cli_host = argv[i] if i < len(argv) else ""
elif arg == "--apikey":
i += 1
cli_key = argv[i] if i < len(argv) else ""
elif arg == "--token-param":
use_token_param = True
elif arg == "--timeout":
i += 1
timeout = int(argv[i]) if i < len(argv) else 120
elif arg == "--json":
i += 1
json_body_str = argv[i] if i < len(argv) else "{}"
else:
positional.append(arg)
i += 1
# Sub-command: configure
if positional and positional[0].lower() == "configure":
if not cli_host and not cli_key:
print(
"Error: --host and --apikey are required for configure", file=sys.stderr
)
sys.exit(1)
cfg_host, cfg_key = read_config()
save_config(cli_host or cfg_host, cli_key or cfg_key)
print("Configuration saved.")
sys.exit(0)
# Normal API call
if len(positional) < 2:
print("Error: expected <METHOD> <PATH>", file=sys.stderr)
print_usage()
sys.exit(1)
method = positional[0].upper()
path = positional[1]
# Remaining positional args are key=value query params
query_params: dict[str, str] = {}
for kv in positional[2:]:
if "=" in kv:
k, _, v = kv.partition("=")
query_params[k] = v
else:
print(f"Warning: ignoring argument without '=': {kv}", file=sys.stderr)
# Parse JSON body
json_body = None
if json_body_str:
try:
json_body = json.loads(json_body_str)
except json.JSONDecodeError as exc:
print(f"Error: invalid JSON body: {exc}", file=sys.stderr)
sys.exit(1)
# Resolve config
host, apikey = resolve_config(cli_host, cli_key)
if not host:
print("Error: backend host is not configured.", file=sys.stderr)
print(" Use: --host HOST or set MP_HOST environment variable", file=sys.stderr)
sys.exit(1)
if not apikey:
print("Error: API key is not configured.", file=sys.stderr)
print(
" Use: --apikey KEY or set MP_API_KEY environment variable",
file=sys.stderr,
)
sys.exit(1)
# Persist if CLI flags provided
if cli_host or cli_key:
save_config(host, apikey)
status, data = api_call(
host=host,
apikey=apikey,
method=method,
path=path,
query_params=query_params if query_params else None,
json_body=json_body,
use_token_param=use_token_param,
timeout=timeout,
)
if status and status not in (200, 201):
print(f"HTTP {status}", file=sys.stderr)
print_json(data)
if status and status >= 400:
sys.exit(1)
if __name__ == "__main__":
main()
+24 -35
View File
@@ -1,81 +1,70 @@
---
name: moviepilot-update
version: 4
version: 5
description: Use this skill to check MoviePilot versions, inspect Release update state, download a Release update in the background, confirm installation, restart MoviePilot, or retain the existing Dev branch update flow. Prefer the built-in system APIs instead of container commands or manual file replacement.
allowed-tools: moviepilot_api
allowed-api-operations: >-
system.versions system.update.status system.update.check system.update.download
system.restart system.update.install system.upgrade.dev
---
# MoviePilot Update
> All script paths are relative to this skill file.
Use this skill for MoviePilot restart and upgrade operations.
## Setup
Use the built-in `moviepilot_api` tool only. The host selects fixed API routes, authenticates with the trusted Agent identity, and applies administrator and confirmation policy. Never request or pass an API token, URL, HTTP method, shell command, or legacy helper script.
This skill reuses the `moviepilot-api` client. When running inside the MoviePilot project, the API client imports `app.runtime.config.settings` and reads the local host, port, and API token directly. Do not ask the user for `API_TOKEN`.
## Preferred Commands
## Operations
### Check versions
```bash
python scripts/mp-update.py versions
```json
{"operation_id":"system.versions","path_params":{},"query":{},"body":{}}
```
This calls `GET /api/v1/system/versions`.
This read-only operation lists available MoviePilot releases.
### Restart MoviePilot
```bash
python scripts/mp-update.py restart
```json
{"operation_id":"system.restart","path_params":{},"query":{},"body":{}}
```
This calls `GET /api/v1/system/restart`.
Restart requires explicit confirmation and interrupts the current Agent session.
### Release update
Check for a stable Release and inspect current progress:
```bash
python scripts/mp-update.py check
python scripts/mp-update.py status
```json
{"operation_id":"system.update.check","path_params":{},"query":{},"body":{}}
{"operation_id":"system.update.status","path_params":{},"query":{},"body":{}}
```
Start the background download. This does not restart MoviePilot:
```bash
python scripts/mp-update.py download
```json
{"operation_id":"system.update.download","path_params":{},"query":{},"body":{}}
```
After `status` reports `state=ready`, installation requires a separate explicit confirmation:
```bash
python scripts/mp-update.py install
```json
{"operation_id":"system.update.install","path_params":{},"query":{},"body":{}}
```
`install` writes the verified install intent and restarts MoviePilot. Do not call it until the user explicitly confirms the restart.
### Dev update and restart
```bash
python scripts/mp-update.py upgrade dev
```json
{"operation_id":"system.upgrade.dev","path_params":{},"query":{},"body":"dev"}
```
Dev mode retains the existing `POST /api/v1/system/upgrade` path with body `"dev"`. It tracks the current v3 development branch during restart. Release mode is no longer accepted by that endpoint.
## Direct API Examples
```bash
python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/restart
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/check
python ../moviepilot-api/scripts/mp-api.py GET /api/v1/system/update/status
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/download
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/update/install
python ../moviepilot-api/scripts/mp-api.py POST /api/v1/system/upgrade --json '"dev"'
```
The body must be the exact JSON string `"dev"`. Stable Release updates must use check, download, status, and install instead.
## Notes
- These operations require administrator authentication.
- All operations require a MoviePilot administrator or a verified notification-channel administrator. The host performs authorization; the model must never invent an administrator flag.
- Only restart, Release installation, and Dev upgrade interrupt the current agent session. Checking and downloading remain online.
- Prefer the API flow above. Only fall back to manual container commands when the API is unavailable.
@@ -1,77 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
API_SCRIPT = SCRIPT_DIR.parents[1] / "moviepilot-api" / "scripts" / "mp-api.py"
def run_api_call(args: list[str]) -> int:
"""调用 MoviePilot REST API 客户端执行更新相关接口。"""
command = [sys.executable, str(API_SCRIPT), *args]
return_code = __import__("subprocess").run(command, check=False).returncode
return return_code
def print_usage() -> None:
"""输出更新脚本的命令行用法。"""
print(
"Usage:\n"
f" python {Path(sys.argv[0]).name} versions\n"
f" python {Path(sys.argv[0]).name} status\n"
f" python {Path(sys.argv[0]).name} check\n"
f" python {Path(sys.argv[0]).name} download\n"
f" python {Path(sys.argv[0]).name} install\n"
f" python {Path(sys.argv[0]).name} restart\n"
f" python {Path(sys.argv[0]).name} upgrade dev"
)
def main() -> int:
"""执行 MoviePilot 更新脚本入口。"""
argv = sys.argv[1:]
if not argv or argv[0] in {"-h", "--help", "help"}:
print_usage()
return 0
command = argv[0].lower()
if command == "versions":
return run_api_call(["GET", "/api/v1/system/versions"])
if command == "restart":
return run_api_call(["GET", "/api/v1/system/restart"])
update_commands = {
"status": ("GET", "/api/v1/system/update/status"),
"check": ("POST", "/api/v1/system/update/check"),
"download": ("POST", "/api/v1/system/update/download"),
"install": ("POST", "/api/v1/system/update/install"),
}
if command in update_commands:
method, path = update_commands[command]
return run_api_call([method, path])
if command == "upgrade":
mode = (argv[1] if len(argv) > 1 else "").strip().lower()
if mode != "dev":
print("Error: only Dev uses upgrade; use check/download/install for Release", file=sys.stderr)
return 1
return run_api_call([
"POST",
"/api/v1/system/upgrade",
"--json",
json.dumps(mode, ensure_ascii=False),
])
print(f"Error: unknown command: {command}", file=sys.stderr)
print_usage()
return 1
if __name__ == "__main__":
sys.exit(main())