mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 18:06:48 +08:00
refactor: complete agent skill API contracts
This commit is contained in:
@@ -116,7 +116,7 @@ class MoviePilotApiExecutor:
|
||||
*,
|
||||
path_params: Mapping[str, Any] | None = None,
|
||||
query: Mapping[str, Any] | None = None,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
body: Any = None,
|
||||
) -> str:
|
||||
"""执行白名单 operation,并把响应转换为稳定 JSON 文本。"""
|
||||
route = resolve_api_route(operation_id)
|
||||
@@ -125,10 +125,12 @@ class MoviePilotApiExecutor:
|
||||
path = self._render_path(route, path_params or {})
|
||||
url = f"{self._resolve_base_url()}{path}"
|
||||
query_data = dict(query or {})
|
||||
body_data = dict(body or {})
|
||||
if route.method == "GET" and body_data:
|
||||
body_data = dict(body) if isinstance(body, Mapping) else body
|
||||
if route.method == "GET" and body_data is not None:
|
||||
if not isinstance(body_data, Mapping):
|
||||
raise ApiExecutionError("GET operation 的 body 必须是 JSON 对象")
|
||||
query_data.update(body_data)
|
||||
body_data = {}
|
||||
body_data = None
|
||||
request = self._request_factory(
|
||||
headers=self._build_headers(),
|
||||
timeout=30,
|
||||
@@ -140,7 +142,7 @@ class MoviePilotApiExecutor:
|
||||
method=route.method,
|
||||
url=url,
|
||||
params=query_data or None,
|
||||
json=body_data or None,
|
||||
json=body_data,
|
||||
raise_exception=True,
|
||||
)
|
||||
if response is None:
|
||||
|
||||
@@ -33,8 +33,9 @@ from app.agent.skills.metadata import (
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.runtime.log import logger
|
||||
|
||||
# 模型返回上限独立于领域层的磁盘读取上限,避免异常内容撑爆上下文。
|
||||
MAX_SKILL_RESULT_CHARS = 64 * 1024
|
||||
# 模型返回上限独立于领域层的磁盘读取上限;需要容纳完整的内置 API
|
||||
# 合同,同时继续阻止接近 1 MiB 磁盘上限的异常 Skill 撑满上下文。
|
||||
MAX_SKILL_RESULT_CHARS = 256 * 1024
|
||||
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
|
||||
|
||||
|
||||
|
||||
+522
-15
@@ -85,6 +85,23 @@ def _admin_read(
|
||||
return _spec(operation_id, required_role=_ADMIN, result_sensitivity=sensitivity)
|
||||
|
||||
|
||||
def _user_write(
|
||||
operation_id: str,
|
||||
*,
|
||||
effect: ActionEffect = ActionEffect.REVERSIBLE_WRITE,
|
||||
recovery: RecoveryMode = _IDEMPOTENT,
|
||||
sensitivity: ResultSensitivity = ResultSensitivity.NORMAL,
|
||||
) -> ApiOperationSpec:
|
||||
"""构造保留普通用户 API 权限的确认写操作。"""
|
||||
return _spec(
|
||||
operation_id,
|
||||
effect=effect,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=recovery,
|
||||
result_sensitivity=sensitivity,
|
||||
)
|
||||
|
||||
|
||||
API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_spec("media.search"),
|
||||
_spec("media.person.search"),
|
||||
@@ -102,18 +119,23 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_spec("subscription.history"),
|
||||
_write("subscription.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
|
||||
_spec("download.add", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, confirmation=_CONFIRM, recovery=_IDEMPOTENT),
|
||||
_spec("download.tasks.active"),
|
||||
_spec("download.clients"),
|
||||
_spec("download.paths"),
|
||||
_spec("download.history.list"),
|
||||
_write("download.history.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
|
||||
_write("transfer.history.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
|
||||
_write("site.update"),
|
||||
_admin_read("site.list"),
|
||||
_spec("site.list"),
|
||||
_admin_read("site.userdata", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("site.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, required_role=_ADMIN, confirmation=_CONFIRM),
|
||||
_spec("site.test"),
|
||||
_write("site.cookie.update", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("recommendation.list"),
|
||||
_admin_read("library.exists"),
|
||||
_spec("library.exists"),
|
||||
_spec("library.latest"),
|
||||
_admin_read("storage.settings"),
|
||||
_admin_read("storage.list", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("transfer.history", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("storage.list", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("transfer.history", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec(
|
||||
"transfer.file",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
@@ -129,7 +151,7 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_admin_read("workflow.list"),
|
||||
_spec("workflow.list"),
|
||||
_spec(
|
||||
"workflow.run",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
@@ -142,6 +164,21 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_admin_read("plugin.capabilities"),
|
||||
_admin_read("plugin.config.get", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("plugin.config.update", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("plugin.source.options"),
|
||||
_spec(
|
||||
"plugin.source.install",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"plugin.source.change",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"plugin.reload",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
@@ -166,9 +203,9 @@ API_FIRST_BATCH_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
API_PARITY_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_spec("search.torrents"),
|
||||
_spec("search.results"),
|
||||
_admin_read("filter.builtin"),
|
||||
_admin_read("filter.custom"),
|
||||
_admin_read("filter.groups"),
|
||||
_spec("filter.builtin"),
|
||||
_spec("filter.custom"),
|
||||
_spec("filter.groups"),
|
||||
_write("filter.custom.add", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("filter.custom.update", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("filter.custom.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.TRANSACTION),
|
||||
@@ -187,8 +224,331 @@ API_PARITY_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
API_MUSIC_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_spec("music.recognize"),
|
||||
_spec("music.explore"),
|
||||
_spec("music.album.get"),
|
||||
_spec("music.album.related"),
|
||||
_spec("music.artist.get"),
|
||||
_spec("music.artist.albums"),
|
||||
_spec("music.artist.related"),
|
||||
_admin_read("music.cache.get", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write(
|
||||
"music.cache.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.NONE,
|
||||
),
|
||||
_write(
|
||||
"music.cache.clear",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.NONE,
|
||||
),
|
||||
)
|
||||
|
||||
API_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (*API_FIRST_BATCH_OPERATION_SPECS, *API_PARITY_OPERATION_SPECS)
|
||||
API_SYSTEM_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_admin_read("system.versions"),
|
||||
_admin_read("system.update.status"),
|
||||
_spec(
|
||||
"system.update.check",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
recovery=_IDEMPOTENT,
|
||||
),
|
||||
_spec(
|
||||
"system.update.download",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"system.restart",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"system.update.install",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"system.upgrade.dev",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
_admin_read("dashboard.media.statistics", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("dashboard.storage", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("dashboard.processes", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("dashboard.system", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("dashboard.downloader", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("scheduler.progress"),
|
||||
_admin_read("dashboard.transfer.statistics"),
|
||||
_admin_read("dashboard.cpu"),
|
||||
_admin_read("dashboard.memory"),
|
||||
_admin_read("dashboard.network"),
|
||||
_spec("media.sources"),
|
||||
_spec("media.recognize_file"),
|
||||
_spec("media.category.config.get"),
|
||||
_write("media.category.config.update", recovery=RecoveryMode.TRANSACTION),
|
||||
_spec("media.categories"),
|
||||
_spec("media.episode_groups"),
|
||||
_spec("media.episode_group.seasons"),
|
||||
_spec("media.seasons"),
|
||||
_spec("search.title", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("search.recommend", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("subtitle.search.title", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("subtitle.search.media", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_write("site.add", recovery=RecoveryMode.TRANSACTION, sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write(
|
||||
"site.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=_DELETE_RECOVERABLE,
|
||||
),
|
||||
_spec("site.auth.options"),
|
||||
_spec(
|
||||
"site.authenticate",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
result_sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_spec(
|
||||
"site.cookiecloud.sync",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_write(
|
||||
"site.reset",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_write("site.priorities.update", recovery=RecoveryMode.TRANSACTION),
|
||||
_spec(
|
||||
"site.userdata.refresh",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=_IDEMPOTENT,
|
||||
result_sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_admin_read("site.userdata.latest", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("site.category"),
|
||||
_spec(
|
||||
"site.resource",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
recovery=_IDEMPOTENT,
|
||||
result_sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_admin_read("site.searchable", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("site.rss", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("site.statistics", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("site.statistic", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("site.mapping", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("site.supporting"),
|
||||
_spec("subscription.get"),
|
||||
_spec("subscription.find"),
|
||||
_user_write(
|
||||
"subscription.delete_by_media",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=_DELETE_RECOVERABLE,
|
||||
),
|
||||
_user_write("subscription.status.update"),
|
||||
_user_write("subscription.reset"),
|
||||
_spec("subscription.search_all", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec(
|
||||
"subscription.refresh",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"subscription.metadata.refresh",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_user_write(
|
||||
"subscription.history.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=_DELETE_RECOVERABLE,
|
||||
),
|
||||
_spec("subscription.user.list"),
|
||||
_spec("subscription.files", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_user_write("subscription.share", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_user_write(
|
||||
"subscription.share.delete",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_user_write("subscription.fork", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_spec("subscription.follow.list"),
|
||||
_user_write("subscription.follow.add"),
|
||||
_user_write("subscription.follow.delete"),
|
||||
_spec("subscription.share.statistics"),
|
||||
_spec(
|
||||
"storage.manage",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
result_sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_write("storage.mkdir", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("storage.rename", recovery=RecoveryMode.RECONCILE),
|
||||
_write(
|
||||
"storage.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_spec("transfer.queue", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_user_write(
|
||||
"transfer.queue.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_admin_read("transfer.name"),
|
||||
_admin_read("transfer.target_path", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("transfer.manual_history", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("transfer.episode_format.recommend", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("transfer.manual_reviews", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("transfer.manual_review", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("transfer.manual_review.resolve", recovery=RecoveryMode.RECONCILE),
|
||||
_spec(
|
||||
"transfer.history.redo",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"transfer.history.redo_batch",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_write(
|
||||
"transfer.history.clear",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_write("workflow.create", recovery=RecoveryMode.TRANSACTION),
|
||||
_admin_read("workflow.get", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("workflow.update", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("workflow.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
|
||||
_admin_read("workflow.actions"),
|
||||
_admin_read("workflow.event_types"),
|
||||
_admin_read("workflow.plugin.actions"),
|
||||
_write("workflow.start"),
|
||||
_write("workflow.pause"),
|
||||
_write("workflow.reset", recovery=RecoveryMode.TRANSACTION),
|
||||
_spec("workflow.shares", required_role=_ADMIN),
|
||||
_spec(
|
||||
"workflow.share",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"workflow.share.delete",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_spec(
|
||||
"workflow.fork",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_admin_read("torrent.cache.get", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("torrent.cache.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.NONE),
|
||||
_write("torrent.cache.clear", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.NONE),
|
||||
_spec(
|
||||
"torrent.cache.refresh",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_write("torrent.cache.reidentify", recovery=RecoveryMode.RECONCILE),
|
||||
_admin_read("database.backups.list", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec(
|
||||
"database.backups.create",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
result_sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_admin_read("database.backups.verify", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write(
|
||||
"database.backups.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_spec("filter.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("system.network.targets"),
|
||||
_spec("system.network.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("system.module.list"),
|
||||
_spec("system.module.test", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec(
|
||||
"plugin.market.sync_wiki",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
required_role=_ADMIN,
|
||||
confirmation=_CONFIRM,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_admin_read("plugin.runtime.status", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("plugin.history"),
|
||||
_admin_read("plugin.releases"),
|
||||
_spec("plugin.ratings"),
|
||||
_spec("plugin.rating"),
|
||||
_user_write("plugin.rating.submit", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=_IDEMPOTENT),
|
||||
_spec("plugin.statistics"),
|
||||
_write(
|
||||
"plugin.reset",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_write("plugin.clone", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_spec("config.user.get", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_spec("config.public.get"),
|
||||
_spec("system.usage.statistics", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_admin_read("plugin.folders.get", sensitivity=ResultSensitivity.PRIVATE),
|
||||
_write("plugin.folders.update", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("plugin.folder.create", recovery=RecoveryMode.TRANSACTION),
|
||||
_write("plugin.folder.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE),
|
||||
_write("plugin.folder.plugins.update", recovery=RecoveryMode.TRANSACTION),
|
||||
)
|
||||
|
||||
|
||||
API_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
*API_FIRST_BATCH_OPERATION_SPECS,
|
||||
*API_PARITY_OPERATION_SPECS,
|
||||
*API_MUSIC_OPERATION_SPECS,
|
||||
*API_SYSTEM_OPERATION_SPECS,
|
||||
*API_EXTENDED_OPERATION_SPECS,
|
||||
)
|
||||
API_OPERATION_BY_ID = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
|
||||
|
||||
|
||||
@@ -201,6 +561,16 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
|
||||
"media.scrape": ApiOperationRoute("POST", "/api/v1/media/scrape/{storage}"),
|
||||
"media.episode_schedule": ApiOperationRoute("GET", "/api/v1/tmdb/{tmdbid}/{season}"),
|
||||
"media.detail": ApiOperationRoute("GET", "/api/v1/media/{media_id}"),
|
||||
"music.recognize": ApiOperationRoute("POST", "/api/v1/music/recognize"),
|
||||
"music.explore": ApiOperationRoute("GET", "/api/v1/music/explore"),
|
||||
"music.album.get": ApiOperationRoute("GET", "/api/v1/music/album/{album_id}"),
|
||||
"music.album.related": ApiOperationRoute("GET", "/api/v1/music/album/{album_id}/related"),
|
||||
"music.artist.get": ApiOperationRoute("GET", "/api/v1/music/artist/{artist_id}"),
|
||||
"music.artist.albums": ApiOperationRoute("GET", "/api/v1/music/artist/{artist_id}/albums"),
|
||||
"music.artist.related": ApiOperationRoute("GET", "/api/v1/music/artist/{artist_id}/related"),
|
||||
"music.cache.get": ApiOperationRoute("GET", "/api/v1/music/cache"),
|
||||
"music.cache.delete": ApiOperationRoute("DELETE", "/api/v1/music/cache/{cache_key}"),
|
||||
"music.cache.clear": ApiOperationRoute("DELETE", "/api/v1/music/cache"),
|
||||
"subscription.add": ApiOperationRoute("POST", "/api/v1/subscribe/"),
|
||||
"subscription.update": ApiOperationRoute("PUT", "/api/v1/subscribe/"),
|
||||
"subscription.search": ApiOperationRoute("GET", "/api/v1/subscribe/search/{subscribe_id}"),
|
||||
@@ -210,28 +580,36 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
|
||||
"subscription.history": ApiOperationRoute("GET", "/api/v1/subscribe/history/{mtype}"),
|
||||
"subscription.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/{subscribe_id}"),
|
||||
"download.add": ApiOperationRoute("POST", "/api/v1/download/add"),
|
||||
"download.tasks.active": ApiOperationRoute("GET", "/api/v1/download/"),
|
||||
"download.clients": ApiOperationRoute("GET", "/api/v1/download/clients"),
|
||||
"download.paths": ApiOperationRoute("GET", "/api/v1/download/paths"),
|
||||
"download.history.list": ApiOperationRoute("GET", "/api/v1/history/download"),
|
||||
"download.history.delete": ApiOperationRoute("DELETE", "/api/v1/history/download"),
|
||||
"transfer.history.delete": ApiOperationRoute("DELETE", "/api/v1/history/transfer"),
|
||||
"site.list": ApiOperationRoute("GET", "/api/v1/site/"),
|
||||
"site.list": ApiOperationRoute("GET", "/api/v1/site/agent"),
|
||||
"site.update": ApiOperationRoute("PUT", "/api/v1/site/"),
|
||||
"site.userdata": ApiOperationRoute("GET", "/api/v1/site/userdata/{site_id}"),
|
||||
"site.test": ApiOperationRoute("GET", "/api/v1/site/test/{site_id}"),
|
||||
"site.cookie.update": ApiOperationRoute("POST", "/api/v1/site/cookie/{site_id}"),
|
||||
"recommendation.list": ApiOperationRoute("GET", "/api/v1/recommend/agent"),
|
||||
"library.exists": ApiOperationRoute("GET", "/api/v1/mediaserver/exists"),
|
||||
"library.latest": ApiOperationRoute("GET", "/api/v1/mediaserver/latest"),
|
||||
"storage.settings": ApiOperationRoute("GET", "/api/v1/storage/directories"),
|
||||
"storage.list": ApiOperationRoute("POST", "/api/v1/storage/list"),
|
||||
"storage.list": ApiOperationRoute("POST", "/api/v1/storage/agent/list"),
|
||||
"transfer.history": ApiOperationRoute("GET", "/api/v1/history/transfer"),
|
||||
"transfer.file": ApiOperationRoute("POST", "/api/v1/transfer/manual"),
|
||||
"scheduler.list": ApiOperationRoute("GET", "/api/v1/dashboard/schedule"),
|
||||
"scheduler.run": ApiOperationRoute("GET", "/api/v1/system/runscheduler"),
|
||||
"workflow.list": ApiOperationRoute("GET", "/api/v1/workflow/"),
|
||||
"workflow.list": ApiOperationRoute("GET", "/api/v1/workflow/agent"),
|
||||
"workflow.run": ApiOperationRoute("POST", "/api/v1/workflow/{workflow_id}/run"),
|
||||
"plugin.installed": ApiOperationRoute("GET", "/api/v1/plugin/installed"),
|
||||
"plugin.installed": ApiOperationRoute("GET", "/api/v1/plugin/"),
|
||||
"plugin.market": ApiOperationRoute("GET", "/api/v1/plugin/"),
|
||||
"plugin.capabilities": ApiOperationRoute("GET", "/api/v1/plugin/runtime/capabilities"),
|
||||
"plugin.config.get": ApiOperationRoute("GET", "/api/v1/plugin/{plugin_id}"),
|
||||
"plugin.config.get": ApiOperationRoute("GET", "/api/v1/plugin/form/{plugin_id}"),
|
||||
"plugin.config.update": ApiOperationRoute("PUT", "/api/v1/plugin/{plugin_id}"),
|
||||
"plugin.source.options": ApiOperationRoute("GET", "/api/v1/plugin/source/{plugin_id}"),
|
||||
"plugin.source.install": ApiOperationRoute("POST", "/api/v1/plugin/source/{plugin_id}/install"),
|
||||
"plugin.source.change": ApiOperationRoute("POST", "/api/v1/plugin/source/{plugin_id}"),
|
||||
"plugin.reload": ApiOperationRoute("GET", "/api/v1/plugin/reload/{plugin_id}"),
|
||||
"plugin.install": ApiOperationRoute("GET", "/api/v1/plugin/install/{plugin_id}"),
|
||||
"plugin.uninstall": ApiOperationRoute("DELETE", "/api/v1/plugin/{plugin_id}"),
|
||||
@@ -253,6 +631,132 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
|
||||
"config.system.get": ApiOperationRoute("GET", "/api/v1/system/settings"),
|
||||
"config.system.update": ApiOperationRoute("POST", "/api/v1/system/settings"),
|
||||
"slash.run": ApiOperationRoute("POST", "/api/v1/message/agent/commands/run"),
|
||||
"system.versions": ApiOperationRoute("GET", "/api/v1/system/versions"),
|
||||
"system.restart": ApiOperationRoute("GET", "/api/v1/system/restart"),
|
||||
"system.update.status": ApiOperationRoute("GET", "/api/v1/system/update/status"),
|
||||
"system.update.check": ApiOperationRoute("POST", "/api/v1/system/update/check"),
|
||||
"system.update.download": ApiOperationRoute("POST", "/api/v1/system/update/download"),
|
||||
"system.update.install": ApiOperationRoute("POST", "/api/v1/system/update/install"),
|
||||
"system.upgrade.dev": ApiOperationRoute("POST", "/api/v1/system/upgrade"),
|
||||
"dashboard.media.statistics": ApiOperationRoute("GET", "/api/v1/dashboard/statistic"),
|
||||
"dashboard.storage": ApiOperationRoute("GET", "/api/v1/dashboard/storage"),
|
||||
"dashboard.processes": ApiOperationRoute("GET", "/api/v1/dashboard/processes"),
|
||||
"dashboard.system": ApiOperationRoute("GET", "/api/v1/dashboard/system"),
|
||||
"dashboard.downloader": ApiOperationRoute("GET", "/api/v1/dashboard/downloader"),
|
||||
"scheduler.progress": ApiOperationRoute("GET", "/api/v1/dashboard/schedule/{job_id}/progress"),
|
||||
"dashboard.transfer.statistics": ApiOperationRoute("GET", "/api/v1/dashboard/transfer"),
|
||||
"dashboard.cpu": ApiOperationRoute("GET", "/api/v1/dashboard/cpu"),
|
||||
"dashboard.memory": ApiOperationRoute("GET", "/api/v1/dashboard/memory"),
|
||||
"dashboard.network": ApiOperationRoute("GET", "/api/v1/dashboard/network"),
|
||||
"media.sources": ApiOperationRoute("GET", "/api/v1/media/source"),
|
||||
"media.recognize_file": ApiOperationRoute("GET", "/api/v1/media/recognize_file"),
|
||||
"media.category.config.get": ApiOperationRoute("GET", "/api/v1/media/category/config"),
|
||||
"media.category.config.update": ApiOperationRoute("POST", "/api/v1/media/category/config"),
|
||||
"media.categories": ApiOperationRoute("GET", "/api/v1/media/category"),
|
||||
"media.episode_groups": ApiOperationRoute("GET", "/api/v1/media/groups/{tmdbid}"),
|
||||
"media.episode_group.seasons": ApiOperationRoute("GET", "/api/v1/media/group/seasons/{episode_group}"),
|
||||
"media.seasons": ApiOperationRoute("GET", "/api/v1/media/seasons"),
|
||||
"search.title": ApiOperationRoute("GET", "/api/v1/search/title"),
|
||||
"search.recommend": ApiOperationRoute("POST", "/api/v1/search/recommend"),
|
||||
"subtitle.search.title": ApiOperationRoute("GET", "/api/v1/search/subtitle/title"),
|
||||
"subtitle.search.media": ApiOperationRoute("GET", "/api/v1/search/subtitle/media/{media_id}"),
|
||||
"site.add": ApiOperationRoute("POST", "/api/v1/site/"),
|
||||
"site.delete": ApiOperationRoute("DELETE", "/api/v1/site/{site_id}"),
|
||||
"site.auth.options": ApiOperationRoute("GET", "/api/v1/site/auth"),
|
||||
"site.authenticate": ApiOperationRoute("POST", "/api/v1/site/auth"),
|
||||
"site.cookiecloud.sync": ApiOperationRoute("GET", "/api/v1/site/cookiecloud"),
|
||||
"site.reset": ApiOperationRoute("GET", "/api/v1/site/reset"),
|
||||
"site.priorities.update": ApiOperationRoute("POST", "/api/v1/site/priorities"),
|
||||
"site.userdata.refresh": ApiOperationRoute("POST", "/api/v1/site/userdata/{site_id}"),
|
||||
"site.userdata.latest": ApiOperationRoute("GET", "/api/v1/site/userdata/latest"),
|
||||
"site.category": ApiOperationRoute("GET", "/api/v1/site/category/{site_id}"),
|
||||
"site.resource": ApiOperationRoute("GET", "/api/v1/site/resource/{site_id}"),
|
||||
"site.searchable": ApiOperationRoute("GET", "/api/v1/site/media/{media_type}"),
|
||||
"site.rss": ApiOperationRoute("GET", "/api/v1/site/rss"),
|
||||
"site.statistics": ApiOperationRoute("GET", "/api/v1/site/statistic"),
|
||||
"site.statistic": ApiOperationRoute("GET", "/api/v1/site/statistic/{site_url}"),
|
||||
"site.mapping": ApiOperationRoute("GET", "/api/v1/site/mapping"),
|
||||
"site.supporting": ApiOperationRoute("GET", "/api/v1/site/supporting"),
|
||||
"subscription.get": ApiOperationRoute("GET", "/api/v1/subscribe/{subscribe_id}"),
|
||||
"subscription.find": ApiOperationRoute("GET", "/api/v1/subscribe/media/{media_id}"),
|
||||
"subscription.delete_by_media": ApiOperationRoute("DELETE", "/api/v1/subscribe/media/{media_id}"),
|
||||
"subscription.status.update": ApiOperationRoute("PUT", "/api/v1/subscribe/status/{subid}"),
|
||||
"subscription.reset": ApiOperationRoute("GET", "/api/v1/subscribe/reset/{subid}"),
|
||||
"subscription.search_all": ApiOperationRoute("GET", "/api/v1/subscribe/search"),
|
||||
"subscription.refresh": ApiOperationRoute("GET", "/api/v1/subscribe/refresh"),
|
||||
"subscription.metadata.refresh": ApiOperationRoute("GET", "/api/v1/subscribe/check"),
|
||||
"subscription.history.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/history/{history_id}"),
|
||||
"subscription.user.list": ApiOperationRoute("GET", "/api/v1/subscribe/user/{username}"),
|
||||
"subscription.files": ApiOperationRoute("GET", "/api/v1/subscribe/files/{subscribe_id}"),
|
||||
"subscription.share": ApiOperationRoute("POST", "/api/v1/subscribe/share"),
|
||||
"subscription.share.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/share/{share_id}"),
|
||||
"subscription.fork": ApiOperationRoute("POST", "/api/v1/subscribe/fork"),
|
||||
"subscription.follow.list": ApiOperationRoute("GET", "/api/v1/subscribe/follow"),
|
||||
"subscription.follow.add": ApiOperationRoute("POST", "/api/v1/subscribe/follow"),
|
||||
"subscription.follow.delete": ApiOperationRoute("DELETE", "/api/v1/subscribe/follow"),
|
||||
"subscription.share.statistics": ApiOperationRoute("GET", "/api/v1/subscribe/share/statistics"),
|
||||
"storage.manage": ApiOperationRoute("POST", "/api/v1/storage/manage"),
|
||||
"storage.mkdir": ApiOperationRoute("POST", "/api/v1/storage/mkdir"),
|
||||
"storage.rename": ApiOperationRoute("POST", "/api/v1/storage/rename"),
|
||||
"storage.delete": ApiOperationRoute("POST", "/api/v1/storage/delete"),
|
||||
"transfer.queue": ApiOperationRoute("GET", "/api/v1/transfer/queue"),
|
||||
"transfer.queue.delete": ApiOperationRoute("DELETE", "/api/v1/transfer/queue"),
|
||||
"transfer.name": ApiOperationRoute("GET", "/api/v1/transfer/name"),
|
||||
"transfer.target_path": ApiOperationRoute("POST", "/api/v1/transfer/manual/target-path"),
|
||||
"transfer.manual_history": ApiOperationRoute("POST", "/api/v1/transfer/manual/history"),
|
||||
"transfer.episode_format.recommend": ApiOperationRoute("POST", "/api/v1/transfer/episode-format/recommend"),
|
||||
"transfer.manual_reviews": ApiOperationRoute("GET", "/api/v1/transfer/tasks/manual-reviews"),
|
||||
"transfer.manual_review": ApiOperationRoute("GET", "/api/v1/transfer/tasks/{task_id}/manual-review"),
|
||||
"transfer.manual_review.resolve": ApiOperationRoute("POST", "/api/v1/transfer/tasks/{task_id}/manual-review"),
|
||||
"transfer.history.redo": ApiOperationRoute("POST", "/api/v1/history/transfer/{history_id}/ai-redo"),
|
||||
"transfer.history.redo_batch": ApiOperationRoute("POST", "/api/v1/history/transfer/ai-redo"),
|
||||
"transfer.history.clear": ApiOperationRoute("GET", "/api/v1/history/empty/transfer"),
|
||||
"workflow.create": ApiOperationRoute("POST", "/api/v1/workflow/"),
|
||||
"workflow.get": ApiOperationRoute("GET", "/api/v1/workflow/{workflow_id}"),
|
||||
"workflow.update": ApiOperationRoute("PUT", "/api/v1/workflow/{workflow_id}"),
|
||||
"workflow.delete": ApiOperationRoute("DELETE", "/api/v1/workflow/{workflow_id}"),
|
||||
"workflow.actions": ApiOperationRoute("GET", "/api/v1/workflow/actions"),
|
||||
"workflow.event_types": ApiOperationRoute("GET", "/api/v1/workflow/event_types"),
|
||||
"workflow.plugin.actions": ApiOperationRoute("GET", "/api/v1/workflow/plugin/actions"),
|
||||
"workflow.start": ApiOperationRoute("POST", "/api/v1/workflow/{workflow_id}/start"),
|
||||
"workflow.pause": ApiOperationRoute("POST", "/api/v1/workflow/{workflow_id}/pause"),
|
||||
"workflow.reset": ApiOperationRoute("POST", "/api/v1/workflow/{workflow_id}/reset"),
|
||||
"workflow.shares": ApiOperationRoute("GET", "/api/v1/workflow/shares"),
|
||||
"workflow.share": ApiOperationRoute("POST", "/api/v1/workflow/share"),
|
||||
"workflow.share.delete": ApiOperationRoute("DELETE", "/api/v1/workflow/share/{share_id}"),
|
||||
"workflow.fork": ApiOperationRoute("POST", "/api/v1/workflow/fork"),
|
||||
"torrent.cache.get": ApiOperationRoute("GET", "/api/v1/torrent/cache"),
|
||||
"torrent.cache.delete": ApiOperationRoute("DELETE", "/api/v1/torrent/cache/{domain}/{torrent_hash}"),
|
||||
"torrent.cache.clear": ApiOperationRoute("DELETE", "/api/v1/torrent/cache"),
|
||||
"torrent.cache.refresh": ApiOperationRoute("POST", "/api/v1/torrent/cache/refresh"),
|
||||
"torrent.cache.reidentify": ApiOperationRoute("POST", "/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}"),
|
||||
"database.backups.list": ApiOperationRoute("GET", "/api/v1/system/database/backups"),
|
||||
"database.backups.create": ApiOperationRoute("POST", "/api/v1/system/database/backups"),
|
||||
"database.backups.verify": ApiOperationRoute("POST", "/api/v1/system/database/backups/{name}/verify"),
|
||||
"database.backups.delete": ApiOperationRoute("DELETE", "/api/v1/system/database/backups/{name}"),
|
||||
"filter.test": ApiOperationRoute("GET", "/api/v1/system/ruletest"),
|
||||
"system.network.targets": ApiOperationRoute("GET", "/api/v1/system/nettest/targets"),
|
||||
"system.network.test": ApiOperationRoute("GET", "/api/v1/system/nettest"),
|
||||
"system.module.list": ApiOperationRoute("GET", "/api/v1/system/modulelist"),
|
||||
"system.module.test": ApiOperationRoute("GET", "/api/v1/system/moduletest/{moduleid}"),
|
||||
"plugin.market.sync_wiki": ApiOperationRoute("POST", "/api/v1/system/setting/PLUGIN_MARKET/sync-wiki"),
|
||||
"plugin.runtime.status": ApiOperationRoute("GET", "/api/v1/plugin/runtime"),
|
||||
"plugin.history": ApiOperationRoute("GET", "/api/v1/plugin/history/{plugin_id}"),
|
||||
"plugin.releases": ApiOperationRoute("GET", "/api/v1/plugin/releases/{plugin_id}"),
|
||||
"plugin.ratings": ApiOperationRoute("GET", "/api/v1/plugin/rating"),
|
||||
"plugin.rating": ApiOperationRoute("GET", "/api/v1/plugin/rating/{plugin_id}"),
|
||||
"plugin.rating.submit": ApiOperationRoute("POST", "/api/v1/plugin/rating/{plugin_id}"),
|
||||
"plugin.statistics": ApiOperationRoute("GET", "/api/v1/plugin/statistic"),
|
||||
"plugin.reset": ApiOperationRoute("GET", "/api/v1/plugin/reset/{plugin_id}"),
|
||||
"plugin.clone": ApiOperationRoute("POST", "/api/v1/plugin/clone/{plugin_id}"),
|
||||
"config.user.get": ApiOperationRoute("GET", "/api/v1/system/global/user"),
|
||||
"config.public.get": ApiOperationRoute("GET", "/api/v1/system/setting/public/{key}"),
|
||||
"system.usage.statistics": ApiOperationRoute("GET", "/api/v1/system/usage/statistic"),
|
||||
"plugin.folders.get": ApiOperationRoute("GET", "/api/v1/plugin/folders"),
|
||||
"plugin.folders.update": ApiOperationRoute("POST", "/api/v1/plugin/folders"),
|
||||
"plugin.folder.create": ApiOperationRoute("POST", "/api/v1/plugin/folders/{folder_name}"),
|
||||
"plugin.folder.delete": ApiOperationRoute("DELETE", "/api/v1/plugin/folders/{folder_name}"),
|
||||
"plugin.folder.plugins.update": ApiOperationRoute("PUT", "/api/v1/plugin/folders/{folder_name}/plugins"),
|
||||
}
|
||||
|
||||
|
||||
@@ -273,10 +777,13 @@ def list_api_operation_ids() -> tuple[str, ...]:
|
||||
|
||||
__all__ = [
|
||||
"API_FIRST_BATCH_OPERATION_SPECS",
|
||||
"API_EXTENDED_OPERATION_SPECS",
|
||||
"API_MUSIC_OPERATION_SPECS",
|
||||
"API_OPERATION_BY_ID",
|
||||
"API_OPERATION_ROUTES",
|
||||
"API_OPERATION_SPECS",
|
||||
"API_PARITY_OPERATION_SPECS",
|
||||
"API_SYSTEM_OPERATION_SPECS",
|
||||
"ApiOperationRoute",
|
||||
"ApiOperationSpec",
|
||||
"list_api_operation_ids",
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""从业务 OpenAPI 构建 moviepilot_api 的外部 MCP 输入合同。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
def _rewrite_schema_refs(
|
||||
schema: Mapping[str, Any],
|
||||
*,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""把 OpenAPI components 引用改写为独立 MCP schema 的 $defs 引用。"""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str) and reference.startswith("#/components/schemas/"):
|
||||
name = reference.rsplit("/", 1)[-1]
|
||||
if name not in definitions:
|
||||
definitions[name] = {}
|
||||
source = components.get(name)
|
||||
if not isinstance(source, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少请求模型: {name}")
|
||||
definitions[name] = _rewrite_schema_refs(
|
||||
source,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
return {"$ref": f"#/$defs/{name}"}
|
||||
|
||||
rewritten: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if isinstance(value, Mapping):
|
||||
rewritten[key] = _rewrite_schema_refs(
|
||||
value,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
rewritten[key] = [
|
||||
_rewrite_schema_refs(
|
||||
item,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
if isinstance(item, Mapping)
|
||||
else deepcopy(item)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
rewritten[key] = deepcopy(value)
|
||||
return rewritten
|
||||
|
||||
|
||||
def _parameter_object_schema(
|
||||
parameters: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
location: str,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""把 OpenAPI path/query 参数投影为网关结构化对象。"""
|
||||
selected = [parameter for parameter in parameters if parameter.get("in") == location]
|
||||
if not selected:
|
||||
return None
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for parameter in selected:
|
||||
name = str(parameter["name"])
|
||||
raw_schema = parameter.get("schema")
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
raw_schema = {}
|
||||
field_schema = _rewrite_schema_refs(
|
||||
raw_schema,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
if parameter.get("description") and "description" not in field_schema:
|
||||
field_schema["description"] = parameter["description"]
|
||||
properties[name] = field_schema
|
||||
if parameter.get("required"):
|
||||
required.append(name)
|
||||
result: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if required:
|
||||
result["required"] = required
|
||||
return result
|
||||
|
||||
|
||||
def _request_body_schema(
|
||||
operation: Mapping[str, Any],
|
||||
*,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""读取一个 OpenAPI operation 的 JSON 请求体及必填性。"""
|
||||
request_body = operation.get("requestBody")
|
||||
if not isinstance(request_body, Mapping):
|
||||
return None, False
|
||||
content = request_body.get("content")
|
||||
if not isinstance(content, Mapping):
|
||||
return None, bool(request_body.get("required"))
|
||||
media = content.get("application/json")
|
||||
if not isinstance(media, Mapping):
|
||||
media = next((item for item in content.values() if isinstance(item, Mapping)), None)
|
||||
raw_schema = media.get("schema") if isinstance(media, Mapping) else None
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
return None, bool(request_body.get("required"))
|
||||
return (
|
||||
_rewrite_schema_refs(
|
||||
raw_schema,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
),
|
||||
bool(request_body.get("required")),
|
||||
)
|
||||
|
||||
|
||||
def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""合并四个来源端点为稳定 media.person.credits 网关合同。"""
|
||||
paths = openapi.get("paths", {})
|
||||
source_paths = {
|
||||
"douban": "/api/v1/douban/person/credits/{person_id}",
|
||||
"tmdb": "/api/v1/tmdb/person/credits/{person_id}",
|
||||
"bangumi": "/api/v1/bangumi/person/credits/{person_id}",
|
||||
"anilist": "/api/v1/anilist/person/credits/{person_id}",
|
||||
}
|
||||
for source_path in source_paths.values():
|
||||
if source_path not in paths:
|
||||
raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}")
|
||||
return {
|
||||
"summary": "读取人物作品",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "source",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string", "enum": list(source_paths)},
|
||||
"description": "人物数据来源。",
|
||||
},
|
||||
{
|
||||
"name": "person_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "来源原生人物 ID。",
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
|
||||
"description": "Bangumi 与 AniList 支持的每页条数;其他来源忽略。",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_openapi_operation(
|
||||
operation_id: str,
|
||||
route: Any,
|
||||
openapi: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
"""按固定路由读取 OpenAPI operation,并处理多来源合成路由。"""
|
||||
if operation_id == "media.person.credits":
|
||||
return _person_credits_operation(openapi)
|
||||
path_item = openapi.get("paths", {}).get(route.path)
|
||||
if not isinstance(path_item, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少 operation 路径: {operation_id} -> {route.path}")
|
||||
operation = path_item.get(str(route.method).lower())
|
||||
if not isinstance(operation, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少 operation 方法: {operation_id} -> {route.method} {route.path}")
|
||||
return operation
|
||||
|
||||
|
||||
def _apply_operation_overrides(
|
||||
operation_id: str,
|
||||
query_schema: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""补充同一路由多 operation 时无法由 FastAPI 自动表达的语义约束。"""
|
||||
if operation_id != "media.person.search" or query_schema is None:
|
||||
return
|
||||
query_schema["properties"]["type"] = {
|
||||
"type": "string",
|
||||
"const": "person",
|
||||
"description": "人物搜索固定传 person。",
|
||||
}
|
||||
required = query_schema.setdefault("required", [])
|
||||
if "type" not in required:
|
||||
required.append("type")
|
||||
|
||||
|
||||
def build_api_mcp_input_schema(
|
||||
*,
|
||||
openapi: Mapping[str, Any],
|
||||
routes: Mapping[str, Any],
|
||||
specs: Sequence[Any],
|
||||
) -> dict[str, Any]:
|
||||
"""构建 59 个白名单 operation 的完整 MCP oneOf 输入合同。"""
|
||||
components = openapi.get("components", {}).get("schemas", {})
|
||||
if not isinstance(components, Mapping):
|
||||
components = {}
|
||||
definitions: dict[str, Any] = {}
|
||||
spec_by_id = {spec.operation_id: spec for spec in specs}
|
||||
if set(spec_by_id) != set(routes):
|
||||
raise ValueError("API operation 策略与路由注册表不一致")
|
||||
|
||||
branches: list[dict[str, Any]] = []
|
||||
for operation_id in sorted(routes):
|
||||
route = routes[operation_id]
|
||||
operation = _resolve_openapi_operation(operation_id, route, openapi)
|
||||
parameters = operation.get("parameters")
|
||||
if not isinstance(parameters, list):
|
||||
parameters = []
|
||||
path_schema = _parameter_object_schema(
|
||||
parameters,
|
||||
location="path",
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
query_schema = _parameter_object_schema(
|
||||
parameters,
|
||||
location="query",
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
_apply_operation_overrides(operation_id, query_schema)
|
||||
body_schema, body_required = _request_body_schema(
|
||||
operation,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
|
||||
properties: dict[str, Any] = {
|
||||
"operation_id": {"type": "string", "const": operation_id},
|
||||
}
|
||||
required = ["operation_id"]
|
||||
if path_schema is not None:
|
||||
properties["path_params"] = path_schema
|
||||
if path_schema.get("required"):
|
||||
required.append("path_params")
|
||||
if query_schema is not None:
|
||||
properties["query"] = query_schema
|
||||
if query_schema.get("required"):
|
||||
required.append("query")
|
||||
if body_schema is not None:
|
||||
properties["body"] = body_schema
|
||||
if body_required:
|
||||
required.append("body")
|
||||
|
||||
summary = str(operation.get("summary") or operation.get("description") or operation_id)
|
||||
spec = spec_by_id[operation_id]
|
||||
branches.append(
|
||||
{
|
||||
"type": "object",
|
||||
"title": operation_id,
|
||||
"description": (
|
||||
f"{summary} Method: {route.method}. Path: {route.path}. "
|
||||
f"Effect: {spec.effect.value}."
|
||||
),
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
|
||||
schema: dict[str, Any] = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "moviepilot_api",
|
||||
"type": "object",
|
||||
"description": "Select the oneOf branch matching operation_id and send exactly its documented fields.",
|
||||
"properties": {
|
||||
"operation_id": {"type": "string", "enum": sorted(routes)},
|
||||
"path_params": {"type": "object"},
|
||||
"query": {"type": "object"},
|
||||
"body": {"type": "object"},
|
||||
},
|
||||
"required": ["operation_id"],
|
||||
"oneOf": branches,
|
||||
}
|
||||
if definitions:
|
||||
schema["$defs"] = definitions
|
||||
return schema
|
||||
|
||||
|
||||
__all__ = ["build_api_mcp_input_schema"]
|
||||
+7412
-193
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,995 @@
|
||||
"""从业务 OpenAPI 构建 moviepilot_api 的外部 MCP 输入合同。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
OPERATION_DESCRIPTIONS = {
|
||||
"config.identifiers.get": "Read the complete custom media-recognition identifier list.",
|
||||
"config.identifiers.update": "Replace the complete custom media-recognition identifier list.",
|
||||
"config.system.get": "Discover registered system settings or read one exact setting.",
|
||||
"config.system.update": "Update one exact registered system setting.",
|
||||
"download.add": "Submit one torrent to MoviePilot's normal download workflow.",
|
||||
"download.clients": "List enabled downloader instance names and provider types without credentials.",
|
||||
"download.history.list": "Page MoviePilot download-history records in reverse chronological order.",
|
||||
"download.history.delete": "Delete one MoviePilot download-history record.",
|
||||
"download.paths": "List configured downloader save-path URIs that may be passed to download.add.",
|
||||
"download.tasks.active": "List currently downloading MoviePilot tasks with their canonical media context.",
|
||||
"filter.builtin": "List built-in torrent filter rules.",
|
||||
"filter.custom": "List user-defined torrent filter rules.",
|
||||
"filter.custom.add": "Create one user-defined torrent filter rule.",
|
||||
"filter.custom.delete": "Delete one user-defined torrent filter rule.",
|
||||
"filter.custom.update": "Update one user-defined torrent filter rule.",
|
||||
"filter.group.add": "Create one named filter-rule group.",
|
||||
"filter.group.delete": "Delete one named filter-rule group.",
|
||||
"filter.group.update": "Update or rename one named filter-rule group.",
|
||||
"filter.groups": "List named filter-rule groups.",
|
||||
"library.exists": "Check configured media servers for one canonical media identity.",
|
||||
"library.latest": "List recently added items from one configured media-server instance for the current user.",
|
||||
"media.detail": "Read canonical media details from one selected metadata source.",
|
||||
"media.episode_schedule": "Read TMDB episode release information for one season.",
|
||||
"media.person.credits": "Read one person's credits from the selected metadata source.",
|
||||
"media.person.search": "Search people across selected metadata sources.",
|
||||
"media.recognize": "Recognize media identity from a title, subtitle, or custom rule context.",
|
||||
"media.scrape": "Generate or refresh metadata for one storage item.",
|
||||
"media.search": "Search canonical media across selected metadata sources.",
|
||||
"music.album.get": "Read one album's details, tracks, releases, and aligned artist names and IDs.",
|
||||
"music.album.related": "Browse albums related to one source-native album identity.",
|
||||
"music.artist.albums": "Browse one artist's albums, singles, EPs, or another exact release-group type.",
|
||||
"music.artist.get": "Read one artist's canonical details from the selected music metadata source.",
|
||||
"music.artist.related": "Browse artists related to one source-native artist identity.",
|
||||
"music.cache.clear": "Clear the complete administrator-only MusicBrainz recognition cache.",
|
||||
"music.cache.delete": "Delete one administrator-only MusicBrainz recognition-cache entry by exact key.",
|
||||
"music.cache.get": "Inspect the administrator-only MusicBrainz recognition cache and summary counts.",
|
||||
"music.explore": "Browse MusicBrainz charts or fresh releases, or Douban Music tag categories.",
|
||||
"music.recognize": "Resolve one recording or album from an exact music source and source-native ID.",
|
||||
"plugin.capabilities": "Inspect the runtime capabilities exposed by installed plugins.",
|
||||
"plugin.config.get": "Read one loaded plugin's configuration form and its defaults merged with saved values.",
|
||||
"plugin.config.update": "Replace one installed plugin's complete configuration and apply it immediately.",
|
||||
"plugin.data": "Read a bounded preview of one plugin's persisted data.",
|
||||
"plugin.install": "Install or update one plugin from an approved source.",
|
||||
"plugin.installed": "List installed plugins and their runtime status.",
|
||||
"plugin.market": "List plugins available from configured marketplaces.",
|
||||
"plugin.reload": "Reload one installed plugin into the running process.",
|
||||
"plugin.source.change": "Switch an installed plugin to one explicitly selected online source revision.",
|
||||
"plugin.source.install": "Install an unbound plugin from one explicitly selected online source.",
|
||||
"plugin.source.options": "Inspect source candidates and the current immutable source identity before installation or source change.",
|
||||
"plugin.uninstall": "Uninstall one plugin and remove it from the installed set.",
|
||||
"recommendation.list": "Read personalized media or music recommendations.",
|
||||
"scheduler.list": "List registered scheduler jobs and their current state.",
|
||||
"scheduler.run": "Run one registered scheduler job immediately.",
|
||||
"search.results": "Read the most recent torrent-search context and result set.",
|
||||
"search.torrents": "Search torrent sites for one canonical media identity.",
|
||||
"site.cookie.update": "Log in to one site and refresh its stored authentication cookie.",
|
||||
"site.list": "List configured sites with status/name filters; authentication fields are returned only to a superuser.",
|
||||
"site.test": "Test connectivity and authentication for one configured site.",
|
||||
"site.update": "Update one configured site's complete settings.",
|
||||
"site.userdata": "Read the latest account statistics collected from one site.",
|
||||
"slash.list": "List slash commands that the Agent may dispatch.",
|
||||
"slash.run": "Execute one complete slash command through MoviePilot messaging.",
|
||||
"storage.list": "List files or directories from one configured storage location.",
|
||||
"storage.settings": "Read configured directory or storage settings.",
|
||||
"subscription.add": "Create one movie, TV, or music subscription.",
|
||||
"subscription.delete": "Delete one active subscription.",
|
||||
"subscription.history": "List completed or archived subscription records.",
|
||||
"subscription.list": "List active subscriptions.",
|
||||
"subscription.popular": "List globally popular subscriptions with filters and pagination.",
|
||||
"subscription.search": "Run an immediate search for one existing subscription.",
|
||||
"subscription.shares": "List shared subscriptions with filters and pagination.",
|
||||
"subscription.update": "Update one existing movie, TV, or music subscription.",
|
||||
"system.restart": "Restart the running MoviePilot process.",
|
||||
"system.update.check": "Check GitHub for the latest stable MoviePilot v3 release.",
|
||||
"system.update.download": "Start downloading and verifying the available stable release in the background.",
|
||||
"system.update.install": "Install the already downloaded and verified stable release, then restart MoviePilot.",
|
||||
"system.update.status": "Read the current stable-release check, download, verification, or install state.",
|
||||
"system.upgrade.dev": "Update to the current v3 development branch and restart MoviePilot.",
|
||||
"system.versions": "List available MoviePilot GitHub releases.",
|
||||
"transfer.file": "Run MoviePilot's manual file-transfer and organization workflow.",
|
||||
"transfer.history": "List file-transfer history with filters and pagination.",
|
||||
"transfer.history.delete": "Delete one transfer-history record and optionally remove files.",
|
||||
"workflow.list": "List configured workflows and their execution state.",
|
||||
"workflow.run": "Run one configured workflow from the beginning or resume point.",
|
||||
"dashboard.cpu": "Read the current host CPU utilization percentage.",
|
||||
"dashboard.downloader": "Read aggregate downloader task counts, speeds, and free-space information.",
|
||||
"dashboard.media.statistics": "Read aggregate movie, TV, episode, and music library counts.",
|
||||
"dashboard.memory": "Read current MoviePilot process and host memory utilization.",
|
||||
"dashboard.network": "Read the current host network receive and transmit counters.",
|
||||
"dashboard.processes": "List host processes visible to the MoviePilot runtime.",
|
||||
"dashboard.storage": "Read local filesystem capacity and free-space information.",
|
||||
"dashboard.system": "Read MoviePilot host, runtime, platform, and uptime summary information.",
|
||||
"dashboard.transfer.statistics": "Read aggregate file-transfer counts grouped by time period.",
|
||||
"database.backups.create": "Create, verify, and atomically publish a managed database backup.",
|
||||
"database.backups.delete": "Delete one exact managed database backup artifact.",
|
||||
"database.backups.list": "List managed database backup artifacts without exposing host paths.",
|
||||
"database.backups.verify": "Verify the integrity of one exact managed database backup artifact.",
|
||||
"filter.test": "Test one title and optional subtitle against an exact named filter-rule group.",
|
||||
"media.categories": "Read the resolved automatic media-category mapping.",
|
||||
"media.category.config.get": "Read the complete automatic media-category strategy configuration.",
|
||||
"media.category.config.update": "Replace the complete automatic media-category strategy configuration.",
|
||||
"media.episode_group.seasons": "List seasons defined by one exact TMDB episode-group identity.",
|
||||
"media.episode_groups": "List alternate TMDB episode groups available for one TV media identity.",
|
||||
"media.recognize_file": "Recognize canonical media identity from one exact filename and optional path context.",
|
||||
"media.seasons": "List seasons for one exact media identity or a title-and-year fallback.",
|
||||
"media.sources": "List metadata sources currently registered for MoviePilot media operations.",
|
||||
"plugin.clone": "Create a configurable clone of one installed plugin.",
|
||||
"plugin.history": "Read marketplace update notes and history for one plugin.",
|
||||
"plugin.market.sync_wiki": "Refresh the configured plugin marketplace repositories from the MoviePilot Wiki.",
|
||||
"plugin.rating": "Read the current aggregate rating for one plugin.",
|
||||
"plugin.rating.submit": "Submit or replace the current user's rating for one plugin.",
|
||||
"plugin.ratings": "Read aggregate ratings for a requested plugin set.",
|
||||
"plugin.releases": "List available release versions for one plugin source.",
|
||||
"plugin.reset": "Delete one plugin's saved configuration and data, then restore its default runtime state.",
|
||||
"plugin.runtime.status": "Read plugin runtime convergence, loading, and failure state.",
|
||||
"plugin.statistics": "Read public installation statistics for plugins.",
|
||||
"scheduler.progress": "Read current progress for one exact scheduler job.",
|
||||
"search.recommend": "Use the configured recommendation model to rank or recommend torrent search results.",
|
||||
"search.title": "Search torrent sites directly from a free-form title and optional media filters.",
|
||||
"site.add": "Create one configured site with its complete authentication and search settings.",
|
||||
"site.auth.options": "List site-account authentication providers and their required input definitions.",
|
||||
"site.authenticate": "Authenticate a supported site account and persist the resulting site authorization state.",
|
||||
"site.category": "List torrent categories supported by one configured site.",
|
||||
"site.cookiecloud.sync": "Start a CookieCloud synchronization of configured sites.",
|
||||
"site.delete": "Delete one configured site by persistent site ID.",
|
||||
"site.mapping": "Read the configured site-domain to site-name mapping.",
|
||||
"site.priorities.update": "Replace priorities for the supplied configured site IDs.",
|
||||
"site.reset": "Delete all configured sites and start a fresh CookieCloud synchronization.",
|
||||
"site.resource": "Browse torrent resources from one configured site with category and keyword filters.",
|
||||
"site.rss": "List configured sites selected for RSS subscription processing.",
|
||||
"site.searchable": "List active configured sites supporting one exact media type.",
|
||||
"site.statistic": "Read account and traffic statistics for one exact configured site domain.",
|
||||
"site.statistics": "Read the latest account and traffic statistics for all configured sites.",
|
||||
"site.supporting": "List indexer definitions supported by the installed MoviePilot resources.",
|
||||
"site.userdata.latest": "Read the latest collected account statistics for every configured site.",
|
||||
"site.userdata.refresh": "Refresh and return account statistics for one configured site.",
|
||||
"storage.delete": "Delete one exact file or directory from a configured storage provider.",
|
||||
"storage.manage": "Run one provider-defined management action against an exact configured storage target.",
|
||||
"storage.mkdir": "Create a named child directory below one exact storage directory item.",
|
||||
"storage.rename": "Rename one exact storage item, optionally applying media-aware recursive renaming.",
|
||||
"subscription.delete_by_media": "Delete accessible subscriptions matching one canonical media identity.",
|
||||
"subscription.files": "Read local library and transfer-file coverage for one accessible subscription.",
|
||||
"subscription.find": "Find one accessible subscription by canonical media identity and optional season.",
|
||||
"subscription.follow.add": "Follow one subscription-sharing user by exact share user ID.",
|
||||
"subscription.follow.delete": "Stop following one subscription-sharing user by exact share user ID.",
|
||||
"subscription.follow.list": "List subscription-sharing user IDs followed by the current user.",
|
||||
"subscription.fork": "Create a local subscription from one shared subscription definition.",
|
||||
"subscription.get": "Read one accessible subscription by persistent subscription ID.",
|
||||
"subscription.history.delete": "Delete one accessible subscription-history record.",
|
||||
"subscription.metadata.refresh": "Start a system-wide refresh of subscription TMDB metadata.",
|
||||
"subscription.refresh": "Start the configured system-wide subscription refresh job.",
|
||||
"subscription.reset": "Reset one accessible subscription so it can be processed again.",
|
||||
"subscription.search_all": "Start immediate searches for all subscriptions accessible to the current user.",
|
||||
"subscription.share": "Publish one accessible subscription to the MoviePilot sharing service.",
|
||||
"subscription.share.delete": "Delete one shared-subscription publication by share ID.",
|
||||
"subscription.share.statistics": "Read aggregate contribution and reuse counts for subscription sharers.",
|
||||
"subscription.status.update": "Set one accessible subscription to running, paused, or stopped state.",
|
||||
"subscription.user.list": "List public subscriptions owned by one accessible MoviePilot username.",
|
||||
"subtitle.search.media": "Search subtitle providers for one canonical media identity and optional season or episode.",
|
||||
"subtitle.search.title": "Search subtitle providers from a free-form title and optional media filters.",
|
||||
"system.module.list": "List loaded MoviePilot module IDs and localized names.",
|
||||
"system.module.test": "Run the built-in availability test for one loaded MoviePilot module.",
|
||||
"system.network.targets": "List approved built-in network-test targets without exposing their request URLs.",
|
||||
"system.network.test": "Test connectivity to one approved target or the legacy constrained URL input.",
|
||||
"torrent.cache.clear": "Delete every cached torrent context.",
|
||||
"torrent.cache.delete": "Delete one cached torrent context by site domain and cache hash.",
|
||||
"torrent.cache.get": "Inspect cached torrent contexts and their recognized media identities.",
|
||||
"torrent.cache.refresh": "Refresh torrent caches from configured RSS or spider sources.",
|
||||
"torrent.cache.reidentify": "Replace or recompute the media identity for one cached torrent context.",
|
||||
"transfer.episode_format.recommend": "Recommend an episode-number extraction template from supplied file samples.",
|
||||
"transfer.history.clear": "Delete every transfer-history record while leaving transferred files untouched.",
|
||||
"transfer.history.redo": "Start AI-assisted reorganization for one transfer-history record.",
|
||||
"transfer.history.redo_batch": "Start AI-assisted reorganization for an explicit list of transfer-history records.",
|
||||
"transfer.manual_history": "Check whether supplied storage items already have successful transfer history.",
|
||||
"transfer.manual_review": "Read one durable transfer task awaiting manual review.",
|
||||
"transfer.manual_review.resolve": "Record the authorized decision for one durable transfer manual-review operation.",
|
||||
"transfer.manual_reviews": "Page durable transfer tasks awaiting manual review or retry recovery.",
|
||||
"transfer.name": "Preview the organized destination name for one source path and media identity.",
|
||||
"transfer.queue": "List items waiting in the file-transfer queue.",
|
||||
"transfer.queue.delete": "Remove one exact storage item from the file-transfer queue and stop its transfer.",
|
||||
"transfer.target_path": "Resolve the configured transfer destination for supplied source storage items.",
|
||||
"workflow.actions": "List built-in workflow action definitions and their parameter contracts.",
|
||||
"workflow.create": "Create one workflow from a complete workflow definition.",
|
||||
"workflow.delete": "Delete one configured workflow by persistent workflow ID.",
|
||||
"workflow.event_types": "List event types that can trigger workflows.",
|
||||
"workflow.fork": "Create a local workflow from one shared workflow definition.",
|
||||
"workflow.get": "Read one complete configured workflow definition.",
|
||||
"workflow.pause": "Disable automatic execution of one configured workflow.",
|
||||
"workflow.plugin.actions": "List workflow actions contributed by installed plugins, optionally filtered by plugin ID.",
|
||||
"workflow.reset": "Reset one configured workflow definition and execution state.",
|
||||
"workflow.share": "Publish one configured workflow to the MoviePilot sharing service.",
|
||||
"workflow.share.delete": "Delete one shared-workflow publication by share ID.",
|
||||
"workflow.shares": "List shared workflows with name and pagination filters.",
|
||||
"workflow.start": "Enable automatic execution of one configured workflow.",
|
||||
"workflow.update": "Replace one configured workflow definition.",
|
||||
"config.user.get": "Read current-user feature flags, runtime capabilities, and effective permissions.",
|
||||
"config.public.get": "Read one explicitly public system setting by exact key.",
|
||||
"system.usage.statistics": "Read the installation version and runtime usage report available to the current user.",
|
||||
"plugin.folders.get": "Read the complete administrator plugin-folder grouping configuration.",
|
||||
"plugin.folders.update": "Replace the complete administrator plugin-folder grouping configuration.",
|
||||
"plugin.folder.create": "Create one named plugin folder.",
|
||||
"plugin.folder.delete": "Delete one named plugin folder without uninstalling its plugins.",
|
||||
"plugin.folder.plugins.update": "Replace the ordered plugin IDs assigned to one named plugin folder.",
|
||||
}
|
||||
|
||||
|
||||
FIELD_DESCRIPTIONS = {
|
||||
"action_name": "Exact action name whose capability contract should be returned.",
|
||||
"allow_unrecognized": "Allow a download when MoviePilot cannot resolve a canonical media identity.",
|
||||
"album": "Album title associated with a music recording or subscription.",
|
||||
"album_id": "Source-native album ID returned by music search, exploration, or artist-album browsing.",
|
||||
"album_type": "MusicBrainz release-group type filter: album, single, ep, broadcast, other, compilation, soundtrack, live, or remix.",
|
||||
"apikey": "Site API key used by sites that support API-key authentication.",
|
||||
"area": "Optional region filter applied by the torrent search workflow.",
|
||||
"artist_id": "Source-native artist ID returned by music search or an album detail response.",
|
||||
"audio_format": "Requested or recorded audio container or codec, such as FLAC or MP3.",
|
||||
"audio_lossless": "Whether the recorded audio result is lossless.",
|
||||
"audio_quality": "Subscription audio-quality rule, such as hires, lossless, or lossy.",
|
||||
"backdrop": "Backdrop image URL stored with the media or subscription.",
|
||||
"background": "Run the transfer asynchronously and return before completion.",
|
||||
"basename": "Base filename without its parent path.",
|
||||
"best_version": "Enable normal best-version upgrading when set to 1.",
|
||||
"best_version_full": "Enable full best-version upgrading when set to 1.",
|
||||
"bit_depth": "Recorded audio bit depth in bits.",
|
||||
"bitrate": "Recorded audio bitrate in bits per second.",
|
||||
"body": (
|
||||
"Request value for the selected operation. Match the exact operation oneOf branch; "
|
||||
"scalar and object request bodies are not interchangeable."
|
||||
),
|
||||
"category": "MoviePilot media category or filter-group category, depending on the operation.",
|
||||
"cache_key": "Exact recognition-cache key returned by music.cache.get.",
|
||||
"channel": "Message channel that originally submitted the download.",
|
||||
"children": "Child storage items nested below this item.",
|
||||
"code": "Two-factor verification code or site-specific authentication secret.",
|
||||
"command": "Complete slash command, including the leading slash and all arguments.",
|
||||
"completed_episode": "Highest episode number already completed for the subscription.",
|
||||
"cookie": "Site authentication cookie. Treat this value as a secret.",
|
||||
"count": "Maximum number of records to return on the requested page.",
|
||||
"current_audio_format": "Audio format of the best version currently held.",
|
||||
"current_bit_depth": "Bit depth of the best version currently held.",
|
||||
"current_bitrate": "Bitrate of the best version currently held.",
|
||||
"current_priority": "Calculated priority of the best version currently held.",
|
||||
"current_sample_rate": "Sample rate of the best version currently held.",
|
||||
"custom_words": "Custom recognition or rename words applied to this media workflow.",
|
||||
"date": "Record creation or completion timestamp used by the history item.",
|
||||
"date_elapsed": "Human-readable age of the torrent publication date.",
|
||||
"days": "Recommendation time window in days.",
|
||||
"deletedest": "Also delete the organized destination files when deleting transfer history.",
|
||||
"deletesrc": "Also delete the recorded source files when deleting transfer history.",
|
||||
"description": "Human-readable media, torrent, or subscription description.",
|
||||
"dest": "Organized destination path recorded in transfer history.",
|
||||
"dest_fileitem": "Serialized destination storage item recorded by the transfer.",
|
||||
"dest_storage": "Configured storage name containing the organized destination.",
|
||||
"directory_type": "Directory configuration subtype to return.",
|
||||
"domain": "Site hostname or domain used for matching and requests.",
|
||||
"download_hash": "Provider-native torrent hash associated with the record.",
|
||||
"downloader": "Configured downloader instance name.",
|
||||
"downloadvolumefactor": "Torrent download-volume multiplier reported by the site.",
|
||||
"douban_sort": "Douban Music category order: U for comprehensive, S for rating, R for newest, or O for hottest.",
|
||||
"drive_id": "Provider-native storage drive identifier.",
|
||||
"effect": "Video or release-effect filter expression used by the subscription.",
|
||||
"entity": "Music exploration entity: recording for tracks or album for release groups.",
|
||||
"enclosure": "Torrent download URL or enclosure supplied by the indexer result.",
|
||||
"episode_detail": "Episode mapping details used by manual transfer.",
|
||||
"episode_format": "Episode-number formatting rule used by manual transfer.",
|
||||
"episode_group": "TMDB episode-group identifier used for alternate episode ordering.",
|
||||
"episode_offset": "Integer offset added to detected episode numbers.",
|
||||
"episode_part": "Episode part number used when one episode is split across files.",
|
||||
"episode_priority": "Per-episode best-version priority state.",
|
||||
"episodes": "Episode-number expression recorded in history, such as E01-E03.",
|
||||
"errmsg": "Error message recorded for a failed transfer.",
|
||||
"expected_revision": "Exact current plugin source-identity revision returned by plugin.source.options.",
|
||||
"exclude": "Regular expression or filter expression that rejects matching releases.",
|
||||
"extension": "Filename extension, including or excluding the leading dot as returned by storage.",
|
||||
"fileid": "Provider-native storage item identifier.",
|
||||
"fileitem": "One complete source storage item returned by storage.list.",
|
||||
"fileitems": "Additional source storage items included in the same manual transfer.",
|
||||
"files": "Serialized list of files recorded by the history item.",
|
||||
"filter": "Named filter rule or rule expression applied to this site or subscription.",
|
||||
"filter_groups": "Ordered filter-rule group names applied to the subscription.",
|
||||
"force": "Force a marketplace refresh or plugin installation when true.",
|
||||
"freedate": "Torrent freeleech expiration timestamp reported by the site.",
|
||||
"freedate_diff": "Seconds remaining until the torrent freeleech period ends.",
|
||||
"fresh_sort": "Freshness ordering used by the recommendation source.",
|
||||
"from_begin": "Restart the workflow from its first action instead of resuming progress.",
|
||||
"from_history": "Treat the transfer input as originating from an existing history record.",
|
||||
"future": "Include future recommendation periods when supported.",
|
||||
"genre_id": "Genre identifier used to filter shared or popular subscriptions.",
|
||||
"grabs": "Number of completed downloads reported for the torrent.",
|
||||
"group": "Registered setting group used for dynamic setting discovery.",
|
||||
"hit_and_run": "Whether the torrent is subject to hit-and-run requirements.",
|
||||
"id": "Persistent database identifier of the supplied record.",
|
||||
"identifiers": "Complete ordered list of custom recognition identifier rules.",
|
||||
"image": "Image URL stored with the history record.",
|
||||
"include": "Regular expression or filter expression that a release must match.",
|
||||
"include_group_refs": "Include custom rules referenced only through rule groups.",
|
||||
"include_usage": "Include the subscriptions or defaults that reference each rule group.",
|
||||
"include_values": "Return complete setting values instead of discovery summaries.",
|
||||
"is_active": "Whether the configured site is enabled.",
|
||||
"jobid": "Exact scheduler job ID returned by scheduler.list.",
|
||||
"key": "Optional exact plugin data key used to narrow the returned preview.",
|
||||
"keyword": "Case-insensitive substring used to discover settings or filter storage entries.",
|
||||
"labels": "Torrent labels supplied by the site result.",
|
||||
"lack_episode": "Number of episodes still missing from the subscription.",
|
||||
"last_update": "Timestamp of the subscription's most recent update.",
|
||||
"library_category_folder": "Create or use a category-level folder in the target library.",
|
||||
"library_type_folder": "Create or use a media-type folder in the target library.",
|
||||
"limit_count": "Maximum number of site requests allowed in one rate-limit interval.",
|
||||
"limit_interval": "Number of requests in the site's rate-limit window.",
|
||||
"limit_seconds": "Site rate-limit window length in seconds.",
|
||||
"logid": "One download-history or transfer-log identifier used by manual transfer.",
|
||||
"logids": "Multiple download-history or transfer-log identifiers included in manual transfer.",
|
||||
"match_field": "Object field used to match one list item during an upsert or removal.",
|
||||
"match_value": "Exact value compared against match_field during a list-item update.",
|
||||
"max_chars": "Maximum number of serialized plugin-data characters to return.",
|
||||
"max_results": "Maximum number of plugin catalog results to return, from 1 to 200.",
|
||||
"max_rating": "Maximum rating used to filter shared or popular subscriptions.",
|
||||
"media_category": "MoviePilot library category assigned to the media.",
|
||||
"media_id": "Source-native media ID. Always pair it with the exact media_source returned by search.",
|
||||
"media_source": "Metadata source identifier. Preserve the exact value returned with media_id.",
|
||||
"media_type": "MoviePilot media type used to filter recommendations or rule groups.",
|
||||
"min_bit_depth": "Minimum acceptable audio bit depth in bits.",
|
||||
"min_bitrate": "Minimum acceptable audio bitrate in bits per second.",
|
||||
"min_filesize": "Minimum source file size accepted by manual transfer, in bytes.",
|
||||
"min_listen_count": "Minimum listen count required for a music recommendation.",
|
||||
"min_rating": "Minimum rating used to filter shared or popular subscriptions.",
|
||||
"min_sample_rate": "Minimum acceptable audio sample rate in hertz.",
|
||||
"min_sub": "Minimum subscriber count used to filter popular subscriptions.",
|
||||
"mode": "Operation mode; music.explore accepts chart or fresh, while transfer history records move, copy, link, or softlink.",
|
||||
"modify_time": "Storage item modification timestamp.",
|
||||
"mtype": "MoviePilot media type or subscription-history category required by the operation.",
|
||||
"music_type": "Music identity level: recording, album, or artist where supported.",
|
||||
"name": "Human-readable name of the site, storage item, subscription, or rule group.",
|
||||
"new_name": "Replacement name for the existing filter-rule group.",
|
||||
"new_rule_id": "Replacement stable ID for the existing custom filter rule.",
|
||||
"note": "Structured auxiliary metadata stored with the record.",
|
||||
"operation": "Setting update mode: replace, merge_dict, upsert_list_item, or remove_list_item.",
|
||||
"operation_id": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
|
||||
"page": "One-based result page number.",
|
||||
"page_url": "Public details page for the torrent result.",
|
||||
"parent_fileid": "Provider-native identifier of the parent storage directory.",
|
||||
"password": "Site login password. Treat this value as a secret.",
|
||||
"past": "Include past recommendation periods when supported.",
|
||||
"path": "Storage or history path represented by this record.",
|
||||
"path_params": (
|
||||
"Resource identities inserted only into the selected operation's fixed route placeholders. "
|
||||
"Use the exact names and types in its oneOf branch."
|
||||
),
|
||||
"peers": "Number of downloading peers reported for the torrent.",
|
||||
"person_id": "Source-native person ID returned by person search.",
|
||||
"pickcode": "115 storage pickcode associated with the item.",
|
||||
"plugin_id": "Exact installed or marketplace plugin ID.",
|
||||
"poster": "Poster image URL stored with the media or subscription.",
|
||||
"preview": "Validate and preview manual-transfer output without committing file changes.",
|
||||
"pri": "Site search priority; lower or higher ordering follows the existing site API convention.",
|
||||
"pri_order": "Indexer priority order assigned to the torrent result.",
|
||||
"proxy": "Whether the site uses MoviePilot's configured proxy.",
|
||||
"public": "Whether the site is treated as a public indexer.",
|
||||
"pubdate": "Torrent publication timestamp.",
|
||||
"publish_time": "Release-age filter expression for a custom filter rule.",
|
||||
"quality": "Video or release quality filter expression.",
|
||||
"query": (
|
||||
"Filters and control values sent in the query string. Use the exact names, types, "
|
||||
"defaults, and enums in the selected operation's oneOf branch."
|
||||
),
|
||||
"range_name": "Named recommendation time range.",
|
||||
"release_version": "Exact plugin release version to install when one is required.",
|
||||
"remove_keys": "Object keys removed after merge_dict applies its supplied value.",
|
||||
"render": "Whether site requests require browser rendering.",
|
||||
"reorganize": "Allow manual transfer to organize an item that was already processed.",
|
||||
"repo_url": "Approved plugin repository URL used to resolve the installation source.",
|
||||
"resolution": "Video resolution filter expression, such as 1080p or 2160p.",
|
||||
"rss": "Site RSS feed URL.",
|
||||
"rule_id": "Stable custom filter-rule ID.",
|
||||
"rule_string": "Ordered filter-rule expression stored in the group.",
|
||||
"sample_rate": "Recorded audio sample rate in hertz.",
|
||||
"save_path": "Configured downloader-side save path for the download or subscription.",
|
||||
"scrape": "Generate metadata and images after manual transfer.",
|
||||
"search_imdbid": "Use IMDb identity during subscription search when set to 1.",
|
||||
"season": "Season number used by the media, search, subscription, or transfer operation.",
|
||||
"seasons": "Season-number expression recorded in history.",
|
||||
"seeders": "Minimum seeder expression for a filter rule, or the torrent's seeder count.",
|
||||
"server": "Exact configured media-server instance name returned by the media-server instance list.",
|
||||
"setting_key": "Exact registered setting key returned by config.system.get discovery.",
|
||||
"show_secrets": "Return unredacted secret values; use only with explicit authorization.",
|
||||
"site": "Source site identifier associated with the torrent result.",
|
||||
"site_cookie": "Site cookie bundled with the torrent result. Treat this value as a secret.",
|
||||
"site_downloader": "Downloader instance selected by the source site.",
|
||||
"site_id": "Persistent site ID returned by site.list.",
|
||||
"site_name": "Human-readable source site name.",
|
||||
"site_order": "Source site's configured search order.",
|
||||
"site_proxy": "Whether the torrent's source site uses the configured proxy.",
|
||||
"site_ua": "User-Agent associated with the source site.",
|
||||
"sites": "Exact site IDs included in the search or subscription scope.",
|
||||
"size": "File or torrent size in bytes.",
|
||||
"size_range": "Accepted torrent size range expression for a custom filter rule.",
|
||||
"sort": "Storage-list sort field or ordering expression.",
|
||||
"sort_by": "Recommendation field used for ordering results.",
|
||||
"sort_type": "Ascending or descending order used by shared or popular subscriptions.",
|
||||
"source": "Exact metadata or recommendation source selected by the operation.",
|
||||
"src": "Source path recorded in transfer history.",
|
||||
"src_fileitem": "Serialized source storage item recorded by the transfer.",
|
||||
"src_storage": "Configured storage name containing the transfer source.",
|
||||
"start_episode": "First episode number requested by the subscription.",
|
||||
"state": "Current site, subscription, marketplace, or transfer state filter.",
|
||||
"status": "Transfer success status used to filter history or describe a record.",
|
||||
"storage": "Configured storage name or storage type used by the operation.",
|
||||
"storage_type": "Configured storage provider type to return.",
|
||||
"stype": "Popular-subscription category requested by the endpoint.",
|
||||
"subscribe_id": "Persistent subscription ID returned by subscription.list.",
|
||||
"subtitle": "Optional subtitle text used together with title during media recognition.",
|
||||
"target_path": "Destination path used by manual transfer.",
|
||||
"target_storage": "Configured storage name receiving the manual transfer.",
|
||||
"tags": "Comma-separated Douban Music category tags; use only with a Douban Music exploration source.",
|
||||
"thumbnail": "Thumbnail URL returned by the storage provider.",
|
||||
"timeout": "Per-request site timeout in seconds.",
|
||||
"title": "Media, torrent, subscription, or history title used by the operation.",
|
||||
"tmdbid": "TMDB media ID returned by media search or detail.",
|
||||
"token": "Site authentication token. Treat this value as a secret.",
|
||||
"torrent_description": "Torrent release description recorded in download history.",
|
||||
"torrent_in": "Complete torrent candidate returned by search.results or search.torrents.",
|
||||
"torrent_name": "Torrent release name recorded in download history.",
|
||||
"torrent_site": "Source site name recorded in download history.",
|
||||
"total_episode": "Expected total episode count for the subscription.",
|
||||
"total_tracks": "Expected or recorded track count for a music item.",
|
||||
"transfer_task_id": "Stable durable transfer-task ID associated with the history record.",
|
||||
"transfer_type": "Manual-transfer mode, such as move, copy, link, or softlink.",
|
||||
"trigger_type": "Workflow trigger filter: timer, event, manual, or all.",
|
||||
"type": "MoviePilot media or storage item type required by the selected operation.",
|
||||
"type_name": "Explicit media type name used when source IDs alone are ambiguous.",
|
||||
"ua": "Site User-Agent string used for authenticated requests.",
|
||||
"uploadvolumefactor": "Torrent upload-volume multiplier reported by the site.",
|
||||
"url": "Site, storage, or torrent URL represented by this field.",
|
||||
"userid": "Message-channel user ID recorded with download history.",
|
||||
"username": "MoviePilot or site username required by the selected operation.",
|
||||
"value": "Complete replacement value, object fragment, or one list item for the selected update mode.",
|
||||
"volume_factor": "Combined upload/download volume-factor label shown for the torrent.",
|
||||
"vote": "Media vote average stored with the subscription.",
|
||||
"with_cover": "Require recommendation results to include cover artwork.",
|
||||
"workdate": "Date used when retrieving one site's historical user statistics.",
|
||||
"workflow_id": "Persistent workflow ID returned by workflow.list.",
|
||||
"year": "Release or premiere year used to disambiguate the media title.",
|
||||
}
|
||||
|
||||
FIELD_DESCRIPTIONS.update(
|
||||
{
|
||||
"action": "Exact provider or workflow action identifier required by the selected operation.",
|
||||
"actions": "Ordered workflow action definitions executed by this workflow or flow.",
|
||||
"add_time": "Timestamp when the workflow definition was created.",
|
||||
"animated": "Whether the workflow connection is rendered as animated in the editor.",
|
||||
"attempt": "Current execution-attempt number for this workflow node.",
|
||||
"attempts": "Attempt counters keyed by workflow node or operation identity.",
|
||||
"backoff": "Retry backoff multiplier applied after each failed workflow action attempt.",
|
||||
"branch_policy": "Workflow branch policy controlling selected downstream paths.",
|
||||
"cat": "Exact site category identifier returned by site.category.",
|
||||
"check_only": "Validate or preview the recommendation without applying search-result filtering.",
|
||||
"concurrency_key": "Workflow expression used to serialize actions sharing the same runtime key.",
|
||||
"condition": "Workflow branch or flow condition expression evaluated at runtime.",
|
||||
"context": "Persisted workflow execution context available to later actions.",
|
||||
"current_action": "Identifier of the workflow action currently selected or executing.",
|
||||
"data": "Serialized workflow action configuration or runtime payload.",
|
||||
"decision": "Manual-review decision selected from the endpoint's declared enum.",
|
||||
"episode": "Episode number used to narrow a subtitle or media search.",
|
||||
"errors": "Workflow execution errors keyed or ordered by action identity.",
|
||||
"event_conditions": "Additional workflow event-filter conditions.",
|
||||
"event_type": "Exact event type returned by workflow.event_types.",
|
||||
"execution_config": "Workflow runtime limits, concurrency, and failure-policy configuration.",
|
||||
"execution_state": "Persisted resumable workflow execution state.",
|
||||
"fail_policy": "Workflow failure policy controlling stop, continue, or branch behavior.",
|
||||
"filetype": "Media file type used to preview the organized destination name.",
|
||||
"filtered_indices": "Zero-based search-result indices selected by the recommendation model.",
|
||||
"finished_actions": "Workflow action IDs already completed in the persisted execution state.",
|
||||
"finished_at": "Timestamp when the workflow node or execution finished.",
|
||||
"flows": "Workflow connection definitions linking action nodes.",
|
||||
"folder_name": "Exact plugin folder name returned by plugin.folders.get.",
|
||||
"genre_ids": "Genre identifiers accepted by the automatic category rule.",
|
||||
"history_id": "Persistent transfer- or subscription-history ID returned by a history operation.",
|
||||
"history_ids": "Explicit persistent transfer-history IDs included in one batch redo request.",
|
||||
"icon": "Icon name or URL used by a workflow, network target, plugin, or category.",
|
||||
"inputs": "Named input bindings consumed by this workflow action.",
|
||||
"interval": "Retry delay in seconds before the next workflow action attempt.",
|
||||
"job_id": "Exact scheduler job ID returned by scheduler.list.",
|
||||
"join_policy": "Workflow fan-in policy controlling when downstream execution may continue.",
|
||||
"last_time": "Timestamp of the workflow's most recent execution.",
|
||||
"max_attempts": "Maximum number of attempts allowed by the workflow retry policy.",
|
||||
"max_workers": "Maximum concurrent workflow actions allowed by the execution configuration.",
|
||||
"message": "Human-readable workflow runtime or provider result message.",
|
||||
"moduleid": "Exact loaded module ID returned by system.module.list.",
|
||||
"movie": "Automatic movie-category rules evaluated in order.",
|
||||
"node_states": "Persisted runtime states keyed by workflow node identity.",
|
||||
"nodes": "Persisted workflow node runtime states keyed by action identity.",
|
||||
"origin_country": "Production-country code matched by an automatic category rule.",
|
||||
"original_language": "Original-language code matched by an automatic category rule.",
|
||||
"outputs": "Named output mappings produced by this workflow action.",
|
||||
"page_size": "Maximum records returned on one page.",
|
||||
"params": "Provider-defined JSON parameters for the selected authentication or storage action.",
|
||||
"plugin_ids": "Exact plugin IDs whose aggregate ratings should be returned.",
|
||||
"position": "Workflow editor coordinates for one action node.",
|
||||
"production_countries": "Production-country codes matched by an automatic category rule.",
|
||||
"progress": "Current numeric or structured workflow execution progress.",
|
||||
"rating": "Numeric plugin rating accepted by the endpoint's declared bounds.",
|
||||
"reason": "Human-readable justification recorded with a manual-review decision.",
|
||||
"recursive": "Apply media-aware renaming recursively to child files when true.",
|
||||
"release_year": "Release year matched by an automatic category rule.",
|
||||
"result": "Persisted workflow action result value.",
|
||||
"result_payload": "Structured external-operation result recorded with manual review.",
|
||||
"retry": "Workflow retry-policy definition for this action.",
|
||||
"rulegroup_name": "Exact filter-rule group name returned by filter.groups.",
|
||||
"run_count": "Number of times the workflow has been executed.",
|
||||
"running_tasks": "Workflow task IDs currently executing.",
|
||||
"runtime": "Persisted workflow runtime metadata used for safe resume.",
|
||||
"share_comment": "Optional explanatory comment published with a shared item.",
|
||||
"share_id": "Persistent MoviePilot Server share ID returned by a share-list operation.",
|
||||
"share_title": "Public title used when publishing a subscription or workflow.",
|
||||
"share_uid": "Exact MoviePilot Server sharing-user ID to follow or unfollow.",
|
||||
"share_user": "Public contributor name used when publishing a subscription or workflow.",
|
||||
"site_url": "Configured site URL or hostname used to select one site's statistics.",
|
||||
"started_at": "Timestamp when the workflow node or execution started.",
|
||||
"subid": "Persistent subscription ID whose status or processing state will change.",
|
||||
"suffix": "File suffix or extension matched by an automatic category rule.",
|
||||
"target": "Exact configured storage target name accepted by storage.manage.",
|
||||
"target_id": "Approved built-in network-test target ID returned by system.network.targets.",
|
||||
"task_id": "Stable durable transfer task ID returned by transfer.manual_reviews.",
|
||||
"timer": "Workflow timer or cron expression used for scheduled execution.",
|
||||
"torrent_hash": "Cache hash returned by torrent.cache.get for one exact site-domain entry.",
|
||||
"tv": "Automatic TV-category rules evaluated in order.",
|
||||
"version": "Plugin release or schema version selected by the operation.",
|
||||
"wiki_url": "Approved MoviePilot Wiki URL used as the plugin-market synchronization source.",
|
||||
"x": "Horizontal workflow editor coordinate.",
|
||||
"y": "Vertical workflow editor coordinate.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
MODEL_DESCRIPTIONS = {
|
||||
"AgentCommandRunRequest": "Slash-command execution request.",
|
||||
"Body_add_api_v1_download_add_post": "MoviePilot download submission request.",
|
||||
"CategoryConfig": "Complete automatic media-category strategy configuration.",
|
||||
"CustomFilterRuleCreateRequest": "Custom filter-rule creation request.",
|
||||
"CustomFilterRuleUpdateRequest": "Custom filter-rule update request.",
|
||||
"CustomIdentifiersUpdateRequest": "Complete custom recognition-identifier replacement request.",
|
||||
"DownloadHistory-Input": "One MoviePilot download-history record.",
|
||||
"FileItem-Input": "One file or directory returned by a configured storage provider.",
|
||||
"FilterRuleGroupCreateRequest": "Filter-rule group creation request.",
|
||||
"FilterRuleGroupUpdateRequest": "Filter-rule group update request.",
|
||||
"JsonData-Input": "Arbitrary JSON-compatible auxiliary data.",
|
||||
"ManualTransferItem": "Manual file-transfer and organization request.",
|
||||
"MediaSource": "Canonical metadata source identifier paired with a source-native media ID.",
|
||||
"MediaType": "MoviePilot media type.",
|
||||
"MusicRecognizeRequest": "Exact source-native recording or album identity to resolve into canonical music metadata.",
|
||||
"PluginSourceChangeRequest": "Explicit online-source change request guarded by the current identity revision.",
|
||||
"PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.",
|
||||
"Site-Input": "Complete site configuration and runtime state.",
|
||||
"SiteCookieUpdate": "Site login request used to refresh the stored cookie and User-Agent.",
|
||||
"Subscribe": "Movie, TV, or music subscription input model.",
|
||||
"SystemSettingsUpdateRequest": "One registered system-setting update request.",
|
||||
"TorrentInfo": "One torrent candidate returned by MoviePilot search.",
|
||||
"TransferHistory-Input": "One MoviePilot file-transfer history record.",
|
||||
}
|
||||
|
||||
MODEL_DESCRIPTIONS.update(
|
||||
{
|
||||
"CategoryRule": "One ordered automatic media-category matching rule.",
|
||||
"PluginCloneRequest": "Plugin clone identifier and optional display-name request.",
|
||||
"PluginMarketSyncRequest": "Approved Wiki source request for plugin-market synchronization.",
|
||||
"PluginRatingRequest": "Current user's numeric plugin-rating submission.",
|
||||
"PluginFoldersData": "Complete mapping from plugin folder names to ordered plugin IDs or display configuration.",
|
||||
"PluginFolderConfigData": "One plugin folder's ordered members and optional presentation settings.",
|
||||
"Body_recommend_search_results_api_v1_search_recommend_post": "Torrent search results and recommendation controls supplied to the configured model.",
|
||||
"SiteAuth": "Supported site-account authentication provider and its exact parameter values.",
|
||||
"SitePriorityUpdate": "One configured site ID and its replacement search priority.",
|
||||
"ManageRequest": "Configured storage target, provider-defined action, and action parameters.",
|
||||
"SubscribeShare": "Shared subscription definition or publication metadata.",
|
||||
"EpisodeFormatRecommendItem": "File samples used to infer an episode-number extraction template.",
|
||||
"BatchTransferHistoryRedoRequest": "Explicit transfer-history IDs for AI-assisted batch reorganization.",
|
||||
"TransferManualReviewRequest": "Authorized decision and optional result for one durable transfer operation.",
|
||||
"Workflow-Input": "Complete workflow definition accepted by create and update operations.",
|
||||
"Action-Input": "One executable action node in a workflow definition.",
|
||||
"ActionPosition": "Editor coordinates for one workflow action node.",
|
||||
"ActionRetry": "Retry limits and timing for one workflow action.",
|
||||
"ActionFlow-Input": "One directed connection between workflow action nodes.",
|
||||
"WorkflowExecutionConfig": "Workflow concurrency, join, branch, and failure policies.",
|
||||
"WorkflowExecutionState-Input": "Persisted resumable workflow execution state.",
|
||||
"WorkflowNodeState": "Persisted runtime state for one workflow action node.",
|
||||
"WorkflowRuntimeState": "Complete persisted workflow runtime and progress state.",
|
||||
"WorkflowShare": "Shared workflow definition or publication metadata.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _apply_field_descriptions(schema: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Attach concrete English guidance without replacing richer endpoint text."""
|
||||
rewritten: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if isinstance(value, Mapping):
|
||||
rewritten[key] = _apply_field_descriptions(value)
|
||||
elif isinstance(value, list):
|
||||
rewritten[key] = [
|
||||
_apply_field_descriptions(item) if isinstance(item, Mapping) else deepcopy(item)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
rewritten[key] = deepcopy(value)
|
||||
properties = rewritten.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
for field_name, field_schema in properties.items():
|
||||
if not isinstance(field_schema, dict):
|
||||
continue
|
||||
existing = field_schema.get("description")
|
||||
if (
|
||||
isinstance(existing, str)
|
||||
and existing.strip()
|
||||
and not re.search(r"[\u3400-\u9fff]", existing)
|
||||
):
|
||||
continue
|
||||
description = FIELD_DESCRIPTIONS.get(str(field_name))
|
||||
if not description:
|
||||
raise ValueError(f"MCP field guidance is missing: {field_name}")
|
||||
field_schema["description"] = description
|
||||
return rewritten
|
||||
|
||||
|
||||
def _rewrite_schema_refs(
|
||||
schema: Mapping[str, Any],
|
||||
*,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""把 OpenAPI components 引用改写为独立 MCP schema 的 $defs 引用。"""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str) and reference.startswith("#/components/schemas/"):
|
||||
name = reference.rsplit("/", 1)[-1]
|
||||
if name not in definitions:
|
||||
definitions[name] = {}
|
||||
source = components.get(name)
|
||||
if not isinstance(source, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少请求模型: {name}")
|
||||
definitions[name] = _rewrite_schema_refs(
|
||||
source,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
return {"$ref": f"#/$defs/{name}"}
|
||||
|
||||
rewritten: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if isinstance(value, Mapping):
|
||||
rewritten[key] = _rewrite_schema_refs(
|
||||
value,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
rewritten[key] = [
|
||||
_rewrite_schema_refs(
|
||||
item,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
if isinstance(item, Mapping)
|
||||
else deepcopy(item)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
rewritten[key] = deepcopy(value)
|
||||
return rewritten
|
||||
|
||||
|
||||
def _parameter_object_schema(
|
||||
parameters: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
location: str,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""把 OpenAPI path/query 参数投影为网关结构化对象。"""
|
||||
selected = [parameter for parameter in parameters if parameter.get("in") == location]
|
||||
if not selected:
|
||||
return None
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for parameter in selected:
|
||||
name = str(parameter["name"])
|
||||
raw_schema = parameter.get("schema")
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
raw_schema = {}
|
||||
field_schema = _rewrite_schema_refs(
|
||||
raw_schema,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
if parameter.get("description") and "description" not in field_schema:
|
||||
field_schema["description"] = parameter["description"]
|
||||
properties[name] = field_schema
|
||||
if parameter.get("required"):
|
||||
required.append(name)
|
||||
result: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if required:
|
||||
result["required"] = required
|
||||
return result
|
||||
|
||||
|
||||
def _request_body_schema(
|
||||
operation: Mapping[str, Any],
|
||||
*,
|
||||
components: Mapping[str, Any],
|
||||
definitions: dict[str, Any],
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""读取一个 OpenAPI operation 的 JSON 请求体及必填性。"""
|
||||
request_body = operation.get("requestBody")
|
||||
if not isinstance(request_body, Mapping):
|
||||
return None, False
|
||||
content = request_body.get("content")
|
||||
if not isinstance(content, Mapping):
|
||||
return None, bool(request_body.get("required"))
|
||||
media = content.get("application/json")
|
||||
if not isinstance(media, Mapping):
|
||||
media = next((item for item in content.values() if isinstance(item, Mapping)), None)
|
||||
raw_schema = media.get("schema") if isinstance(media, Mapping) else None
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
return None, bool(request_body.get("required"))
|
||||
return (
|
||||
_rewrite_schema_refs(
|
||||
raw_schema,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
),
|
||||
bool(request_body.get("required")),
|
||||
)
|
||||
|
||||
|
||||
def _person_credits_operation(openapi: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""合并四个来源端点为稳定 media.person.credits 网关合同。"""
|
||||
paths = openapi.get("paths", {})
|
||||
source_paths = {
|
||||
"douban": "/api/v1/douban/person/credits/{person_id}",
|
||||
"tmdb": "/api/v1/tmdb/person/credits/{person_id}",
|
||||
"bangumi": "/api/v1/bangumi/person/credits/{person_id}",
|
||||
"anilist": "/api/v1/anilist/person/credits/{person_id}",
|
||||
}
|
||||
for source_path in source_paths.values():
|
||||
if source_path not in paths:
|
||||
raise ValueError(f"OpenAPI 缺少人物作品端点: {source_path}")
|
||||
return {
|
||||
"summary": "Read person credits",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "source",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string", "enum": list(source_paths)},
|
||||
"description": "Metadata source that owns the person ID.",
|
||||
},
|
||||
{
|
||||
"name": "person_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "Source-native person ID.",
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
|
||||
"description": "Page size used by Bangumi and AniList; other sources ignore it.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_openapi_operation(
|
||||
operation_id: str,
|
||||
route: Any,
|
||||
openapi: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
"""按固定路由读取 OpenAPI operation,并处理多来源合成路由。"""
|
||||
if operation_id == "media.person.credits":
|
||||
return _person_credits_operation(openapi)
|
||||
path_item = openapi.get("paths", {}).get(route.path)
|
||||
if not isinstance(path_item, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少 operation 路径: {operation_id} -> {route.path}")
|
||||
operation = path_item.get(str(route.method).lower())
|
||||
if not isinstance(operation, Mapping):
|
||||
raise ValueError(f"OpenAPI 缺少 operation 方法: {operation_id} -> {route.method} {route.path}")
|
||||
return operation
|
||||
|
||||
|
||||
def _apply_operation_overrides(
|
||||
operation_id: str,
|
||||
query_schema: dict[str, Any] | None,
|
||||
body_schema: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any] | None, bool | None]:
|
||||
"""补充同一路由多 operation 时无法由 FastAPI 自动表达的语义约束。"""
|
||||
if operation_id == "media.person.search" and query_schema is not None:
|
||||
query_schema["properties"]["type"] = {
|
||||
"type": "string",
|
||||
"const": "person",
|
||||
"description": "Literal person, selecting person search instead of media search.",
|
||||
}
|
||||
required = query_schema.setdefault("required", [])
|
||||
if "type" not in required:
|
||||
required.append("type")
|
||||
if operation_id in {"plugin.installed", "plugin.market"} and query_schema is not None:
|
||||
state = "installed" if operation_id == "plugin.installed" else "market"
|
||||
query_schema["properties"]["query"]["description"] = (
|
||||
"Optional case-insensitive keyword matched against plugin ID, name, description, and author."
|
||||
)
|
||||
query_schema["properties"]["state"] = {
|
||||
"type": "string",
|
||||
"const": state,
|
||||
"description": f"Literal {state}, selecting only {state} plugin catalog entries.",
|
||||
}
|
||||
required = query_schema.setdefault("required", [])
|
||||
if "state" not in required:
|
||||
required.append("state")
|
||||
if operation_id == "music.explore" and query_schema is not None:
|
||||
descriptions = {
|
||||
"media_source": (
|
||||
"Music exploration source. Use musicbrainz for chart/fresh modes or doubanmusic for tag browsing."
|
||||
),
|
||||
"mode": "MusicBrainz mode: chart reads listening charts; fresh reads new album releases.",
|
||||
"entity": "Chart entity: recording for tracks or album for release groups. Fresh results are albums.",
|
||||
"range_name": "ListenBrainz chart range: this_week, this_month, this_year, week, month, or year.",
|
||||
"sort_by": "ListenBrainz chart order: listen_count.desc or listen_count.asc.",
|
||||
"sort": "Fresh-release order accepted by the current ListenBrainz implementation.",
|
||||
"days": "Fresh-release lookback/lookahead window, from 1 through the endpoint maximum.",
|
||||
"past": "Include releases before today in fresh mode.",
|
||||
"future": "Include releases after today in fresh mode.",
|
||||
"min_listen_count": "Minimum ListenBrainz listen count in chart mode.",
|
||||
"with_cover": "Keep only results with cover artwork when true.",
|
||||
"tags": "Comma-separated Douban Music tags used only when media_source is doubanmusic.",
|
||||
"douban_sort": "Douban Music order: U comprehensive, S rating, R newest, or O hottest.",
|
||||
}
|
||||
for field_name, description in descriptions.items():
|
||||
field_schema = query_schema["properties"].get(field_name)
|
||||
if isinstance(field_schema, dict):
|
||||
field_schema["description"] = description
|
||||
if operation_id == "plugin.config.update" and body_schema is not None:
|
||||
body_schema["minProperties"] = 1
|
||||
body_schema["description"] = (
|
||||
"Complete plugin configuration object. First call plugin.config.get, copy its returned model, "
|
||||
"change only the intended keys, and submit the full resulting object. Omit a key only when it "
|
||||
"must be removed. A successful update reinitializes the plugin and refreshes commands, jobs, and routes."
|
||||
)
|
||||
if operation_id == "system.upgrade.dev":
|
||||
return (
|
||||
{
|
||||
"type": "string",
|
||||
"const": "dev",
|
||||
"description": "Literal dev. Release updates must use the separate check, download, and install operations.",
|
||||
},
|
||||
True,
|
||||
)
|
||||
return body_schema, None
|
||||
|
||||
|
||||
def build_api_mcp_input_schema(
|
||||
*,
|
||||
openapi: Mapping[str, Any],
|
||||
routes: Mapping[str, Any],
|
||||
specs: Sequence[Any],
|
||||
) -> dict[str, Any]:
|
||||
"""构建全部白名单 operation 的完整 MCP oneOf 输入合同。"""
|
||||
components = openapi.get("components", {}).get("schemas", {})
|
||||
if not isinstance(components, Mapping):
|
||||
components = {}
|
||||
definitions: dict[str, Any] = {}
|
||||
spec_by_id = {spec.operation_id: spec for spec in specs}
|
||||
if set(spec_by_id) != set(routes):
|
||||
raise ValueError("API operation 策略与路由注册表不一致")
|
||||
|
||||
branches: list[dict[str, Any]] = []
|
||||
for operation_id in sorted(routes):
|
||||
route = routes[operation_id]
|
||||
operation = _resolve_openapi_operation(operation_id, route, openapi)
|
||||
summary = OPERATION_DESCRIPTIONS.get(operation_id)
|
||||
if not summary:
|
||||
raise ValueError(f"MCP operation guidance is missing: {operation_id}")
|
||||
parameters = operation.get("parameters")
|
||||
if not isinstance(parameters, list):
|
||||
parameters = []
|
||||
path_schema = _parameter_object_schema(
|
||||
parameters,
|
||||
location="path",
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
query_schema = _parameter_object_schema(
|
||||
parameters,
|
||||
location="query",
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
body_schema, body_required = _request_body_schema(
|
||||
operation,
|
||||
components=components,
|
||||
definitions=definitions,
|
||||
)
|
||||
body_schema, required_override = _apply_operation_overrides(
|
||||
operation_id,
|
||||
query_schema,
|
||||
body_schema,
|
||||
)
|
||||
if required_override is not None:
|
||||
body_required = required_override
|
||||
|
||||
properties: dict[str, Any] = {
|
||||
"operation_id": {"type": "string", "const": operation_id},
|
||||
}
|
||||
required = ["operation_id"]
|
||||
if path_schema is not None:
|
||||
path_schema["description"] = (
|
||||
f"Resource identity placeholders for {operation_id}. {summary} "
|
||||
"Use only the named fields below."
|
||||
)
|
||||
properties["path_params"] = path_schema
|
||||
if path_schema.get("required"):
|
||||
required.append("path_params")
|
||||
if query_schema is not None:
|
||||
query_schema["description"] = (
|
||||
f"Filters and control values for {operation_id}. {summary} "
|
||||
"Use only the named fields below."
|
||||
)
|
||||
properties["query"] = query_schema
|
||||
if query_schema.get("required"):
|
||||
required.append("query")
|
||||
if body_schema is not None:
|
||||
body_schema.setdefault(
|
||||
"description",
|
||||
f"Request value for {operation_id}. {summary} Use the exact type and fields below.",
|
||||
)
|
||||
properties["body"] = body_schema
|
||||
if body_required:
|
||||
required.append("body")
|
||||
|
||||
spec = spec_by_id[operation_id]
|
||||
branches.append(
|
||||
{
|
||||
"type": "object",
|
||||
"title": operation_id,
|
||||
"description": (
|
||||
f"{summary} Method: {route.method}. Path: {route.path}. "
|
||||
f"Effect: {spec.effect.value}."
|
||||
),
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
|
||||
schema: dict[str, Any] = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "moviepilot_api",
|
||||
"type": "object",
|
||||
"description": "Select the oneOf branch matching operation_id and send exactly its documented fields.",
|
||||
"properties": {
|
||||
"operation_id": {"type": "string", "enum": sorted(routes)},
|
||||
"path_params": {"type": "object"},
|
||||
"query": {"type": "object"},
|
||||
"body": {},
|
||||
},
|
||||
"required": ["operation_id"],
|
||||
"oneOf": branches,
|
||||
}
|
||||
if definitions:
|
||||
for model_name, definition in definitions.items():
|
||||
description = MODEL_DESCRIPTIONS.get(model_name)
|
||||
if not description:
|
||||
raise ValueError(f"MCP model guidance is missing: {model_name}")
|
||||
definition["description"] = description
|
||||
schema["$defs"] = definitions
|
||||
return _apply_field_descriptions(schema)
|
||||
|
||||
|
||||
__all__ = ["build_api_mcp_input_schema"]
|
||||
@@ -49,6 +49,7 @@ BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
|
||||
"moviepilot_api",
|
||||
"downloader_operation",
|
||||
"mediaserver_operation",
|
||||
"database_operation",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<confirmation_policy>
|
||||
- Do not stop for approval on read-only operations.
|
||||
- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, load the relevant configuration Skill and call `moviepilot_api` with `operation_id=config.system.get` and `body.show_secrets=true`; do not refuse solely because the value is sensitive. The host verifies administrator authority, obtains confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
|
||||
- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, load the relevant configuration Skill and call `moviepilot_api` with `operation_id=config.system.get` and `query.show_secrets=true`; do not refuse solely because the value is sensitive. The host verifies administrator authority, obtains confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
|
||||
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
|
||||
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
|
||||
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `agent_task` with `action=create` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks through the same tool with `action=list|update|run|delete`; these actions use integer `task_id` values. Use `moviepilot_api` operations `scheduler.list` and `scheduler.run` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to `agent_task`.
|
||||
|
||||
@@ -17,7 +17,11 @@ from app.agent.tools.impl.search_web import SearchWebTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.agent.tools.impl.service_operation import DownloaderOperationTool, MediaServerOperationTool
|
||||
from app.agent.tools.impl.service import (
|
||||
DatabaseOperationTool,
|
||||
DownloaderOperationTool,
|
||||
MediaServerOperationTool,
|
||||
)
|
||||
from app.agent.tools.impl.write_file import WriteFileTool
|
||||
from app.application.agent import AgentDataContext
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
@@ -65,6 +69,7 @@ class MoviePilotToolFactory:
|
||||
EXTERNAL_SERVICE_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
|
||||
DownloaderOperationTool,
|
||||
MediaServerOperationTool,
|
||||
DatabaseOperationTool,
|
||||
)
|
||||
|
||||
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
||||
|
||||
+73
-30
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor
|
||||
from app.agent.policy.api import resolve_api_operation
|
||||
from app.agent.policy.contracts import PrincipalRole
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.schemas.types import NotificationChannel
|
||||
@@ -31,20 +32,24 @@ class MoviePilotApiInput(BaseModel): # type: ignore[misc]
|
||||
operation_id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"稳定的 MoviePilot API operation ID。先根据领域 Skill 选择操作,不要传入 URL、认证头或 API Token。"
|
||||
"Exact allowlisted MoviePilot operation ID selected from the loaded domain Skill. "
|
||||
"Never supply a URL, authentication header, or API token."
|
||||
),
|
||||
)
|
||||
path_params: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="路径参数;仅填当前 operation 声明的参数。",
|
||||
description="Route placeholder values declared by the selected operation.",
|
||||
)
|
||||
query: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="查询参数;仅填当前 operation 声明的参数。",
|
||||
description="Query-string fields declared by the selected operation.",
|
||||
)
|
||||
body: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="JSON 请求体;字段由当前 operation 的 Skill 合同定义。",
|
||||
body: Any = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"JSON request value declared by the selected operation and its loaded Skill contract. "
|
||||
"Most operations use an object; a oneOf branch may require an exact scalar."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -66,9 +71,9 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
ToolTag.Plugin,
|
||||
]
|
||||
description: str = (
|
||||
"调用经过白名单审核的 MoviePilot 业务 API。使用领域 Skill 获取 operation_id、"
|
||||
"参数和失败处理;外部 MCP tools/list 会为每个 operation 提供完整 oneOf 参数合同;"
|
||||
"不能调用任意 URL、命令或认证接口。"
|
||||
"Call allowlisted MoviePilot business APIs. Use the domain Skill to select operation_id, "
|
||||
"parameters, and failure handling. External MCP tools/list exposes one complete oneOf "
|
||||
"branch per operation. Arbitrary URLs, commands, and authentication endpoints are forbidden."
|
||||
)
|
||||
require_admin: bool = False
|
||||
args_schema: Type[BaseModel] = MoviePilotApiInput
|
||||
@@ -96,24 +101,42 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
"""返回包含全部白名单 operation 精确参数的 MCP JSON Schema。"""
|
||||
return deepcopy(_load_api_mcp_input_schema())
|
||||
|
||||
async def _resolve_api_identity(self) -> tuple[str, Optional[str], bool]:
|
||||
"""把 Web 或渠道身份解析为真实 MoviePilot 用户身份。"""
|
||||
async def _resolve_superuser_integration_identity(
|
||||
self,
|
||||
) -> tuple[str, Optional[str], bool]:
|
||||
"""为已验证的管理员集成解析一个真实持久化超级管理员身份。"""
|
||||
from app.application.security.auth import build_superuser_token_payload
|
||||
|
||||
payload = await self.run_blocking(
|
||||
"db",
|
||||
build_superuser_token_payload,
|
||||
)
|
||||
if payload.sub is None:
|
||||
raise ApiExecutionError("管理员集成身份没有持久化用户 ID")
|
||||
return str(payload.sub), payload.username, bool(payload.super_user)
|
||||
|
||||
async def _resolve_api_identity(
|
||||
self,
|
||||
*,
|
||||
require_system_admin: bool = False,
|
||||
) -> tuple[str, Optional[str], bool]:
|
||||
"""把 Web、渠道或集成身份解析为真实 MoviePilot API 用户身份。"""
|
||||
raw_user_id = str(self._user_id or "")
|
||||
if self._source == "api" and bool(self._agent_context.get("is_admin")):
|
||||
from app.application.security.auth import build_superuser_token_payload
|
||||
|
||||
payload = await self.run_blocking(
|
||||
"db",
|
||||
build_superuser_token_payload,
|
||||
)
|
||||
if payload.sub is None:
|
||||
raise ApiExecutionError("管理员集成身份没有持久化用户 ID")
|
||||
return str(payload.sub), payload.username, bool(payload.super_user)
|
||||
return await self._resolve_superuser_integration_identity()
|
||||
direct_user_channels = {
|
||||
NotificationChannel.Web.value,
|
||||
NotificationChannel.WebAgent.value,
|
||||
}
|
||||
is_direct_user_channel = self._channel in direct_user_channels
|
||||
if (
|
||||
require_system_admin
|
||||
and bool(self._agent_context.get("is_admin"))
|
||||
and not is_direct_user_channel
|
||||
):
|
||||
# 通知渠道管理员延续旧管理员工具语义,但只在管理员 operation
|
||||
# 上借用系统管理员集成身份;普通 operation 仍解析其绑定用户。
|
||||
return await self._resolve_superuser_integration_identity()
|
||||
if self._data is None:
|
||||
if is_direct_user_channel and raw_user_id.isdigit():
|
||||
return raw_user_id, self._username, bool(self._agent_context.get("is_admin"))
|
||||
@@ -154,11 +177,19 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
raise ApiExecutionError("当前 Agent 身份未绑定有效的 MoviePilot 用户")
|
||||
return str(user.id), user.name, bool(user.is_superuser)
|
||||
|
||||
async def _get_executor(self) -> MoviePilotApiExecutor:
|
||||
"""返回注入执行器,未注入时按当前可信 Agent 身份创建。"""
|
||||
if self._executor is None:
|
||||
user_id, username, is_admin = await self._resolve_api_identity()
|
||||
self._executor = MoviePilotApiExecutor(
|
||||
async def _get_executor(
|
||||
self,
|
||||
*,
|
||||
require_system_admin: bool = False,
|
||||
) -> tuple[MoviePilotApiExecutor, bool]:
|
||||
"""返回当前 operation 的执行器及其管理员身份事实。"""
|
||||
if self._executor is not None:
|
||||
return self._executor, await self.is_admin_user()
|
||||
user_id, username, is_admin = await self._resolve_api_identity(
|
||||
require_system_admin=require_system_admin,
|
||||
)
|
||||
return (
|
||||
MoviePilotApiExecutor(
|
||||
context=ApiExecutionContext(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
@@ -167,15 +198,16 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
channel=self._channel,
|
||||
source=self._source,
|
||||
)
|
||||
)
|
||||
return self._executor
|
||||
),
|
||||
is_admin,
|
||||
)
|
||||
|
||||
async def run( # type: ignore[override]
|
||||
self,
|
||||
operation_id: str,
|
||||
path_params: Optional[Dict[str, Any]] = None,
|
||||
query: Optional[Dict[str, Any]] = None,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
body: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""
|
||||
@@ -198,9 +230,20 @@ class MoviePilotApiTool(MoviePilotTool):
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
executor = await self._get_executor()
|
||||
requires_system_admin = spec.required_role is PrincipalRole.SYSTEM_ADMIN
|
||||
executor, is_admin = await self._get_executor(
|
||||
require_system_admin=requires_system_admin,
|
||||
)
|
||||
if requires_system_admin and not is_admin:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "permission_denied",
|
||||
"message": "This MoviePilot API operation requires a system administrator.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return await executor.execute(
|
||||
operation_id,
|
||||
path_params=path_params,
|
||||
|
||||
@@ -20,9 +20,17 @@ from app.runtime.settings import get_runtime_setting
|
||||
class DownloaderOperationInput(BaseModel):
|
||||
"""下载器操作工具的运行时输入模型。"""
|
||||
|
||||
client: Optional[str] = Field(default=None, description="下载器实例名;省略时使用默认或唯一实例。")
|
||||
action: str = Field(description="固定下载器 action;MCP inputSchema 会按 action 展示精确枚举和参数。")
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict, description="当前 action 的结构化参数对象。")
|
||||
client: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Configured downloader instance name; omit it to use the default or only enabled instance.",
|
||||
)
|
||||
action: str = Field(
|
||||
description="Exact downloader action. Select one action branch from the MCP input schema.",
|
||||
)
|
||||
arguments: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Structured arguments declared by the selected downloader action.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -30,14 +38,36 @@ class DownloaderOperationInput(BaseModel):
|
||||
class MediaServerOperationInput(BaseModel):
|
||||
"""媒体服务器操作工具的运行时输入模型。"""
|
||||
|
||||
server: Optional[str] = Field(default=None, description="媒体服务器实例名;省略时使用唯一启用实例。")
|
||||
action: str = Field(description="固定媒体服务器 action;MCP inputSchema 会按 action 展示精确枚举和参数。")
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict, description="当前 action 的结构化参数对象。")
|
||||
server: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Configured media-server instance name; omit it to use the only enabled instance.",
|
||||
)
|
||||
action: str = Field(
|
||||
description="Exact media-server action. Select one action branch from the MCP input schema.",
|
||||
)
|
||||
arguments: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Structured arguments declared by the selected media-server action.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
class DatabaseOperationInput(BaseModel):
|
||||
"""数据库操作工具的运行时输入模型。"""
|
||||
|
||||
action: str = Field(
|
||||
description="Exact database action. Select one action branch from the MCP input schema.",
|
||||
)
|
||||
arguments: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Structured arguments declared by the selected database action.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
def _load_action_contracts(script_path: str) -> dict[str, Any]:
|
||||
"""从固定 Skill 脚本加载无配置副作用的 action 注册表。"""
|
||||
namespace = runpy.run_path(script_path)
|
||||
@@ -105,18 +135,23 @@ def _metadata_refresh_items_schema() -> dict[str, Any]:
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "媒体标题。"},
|
||||
"title": {"type": "string", "description": "Media title used for recognition."},
|
||||
"year": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}],
|
||||
"description": "媒体年份。",
|
||||
"description": "Release or premiere year used to disambiguate the title.",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["电影", "电视剧", "音乐"],
|
||||
"description": "MoviePilot 媒体类型。",
|
||||
"description": (
|
||||
"Exact MoviePilot media-type literal: 电影 (movie), 电视剧 (TV), or 音乐 (music)."
|
||||
),
|
||||
},
|
||||
"category": {"type": "string", "description": "Optional MoviePilot library category."},
|
||||
"target_path": {
|
||||
"type": "string",
|
||||
"description": "Media file or directory path whose metadata should be refreshed.",
|
||||
},
|
||||
"category": {"type": "string", "description": "媒体分类。"},
|
||||
"target_path": {"type": "string", "description": "媒体文件或目录路径。"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
@@ -163,6 +198,11 @@ def _add_action_argument_rules(action: str, schema: dict[str, Any]) -> None:
|
||||
]
|
||||
if action == "items.season_episodes":
|
||||
schema["anyOf"] = [{"required": ["item_id"]}, {"required": ["title"]}]
|
||||
if action in {"query", "write"}:
|
||||
schema["oneOf"] = [
|
||||
{"required": ["sql"], "not": {"required": ["file"]}},
|
||||
{"required": ["file"], "not": {"required": ["sql"]}},
|
||||
]
|
||||
|
||||
|
||||
def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]:
|
||||
@@ -177,7 +217,7 @@ def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]:
|
||||
properties["items"]["description"] = contract["arguments"][0]["description"]
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"description": ";".join(contract.get("argument_rules") or []),
|
||||
"description": " ".join(contract.get("argument_rules") or []),
|
||||
"properties": properties,
|
||||
"required": list(contract.get("required_arguments") or []),
|
||||
"additionalProperties": False,
|
||||
@@ -189,8 +229,8 @@ def _build_arguments_schema(action: str, spec: Any) -> dict[str, Any]:
|
||||
def _build_mcp_input_schema(
|
||||
*,
|
||||
actions: dict[str, Any],
|
||||
selector_name: str,
|
||||
selector_description: str,
|
||||
selector_name: Optional[str],
|
||||
selector_description: Optional[str],
|
||||
title: str,
|
||||
) -> dict[str, Any]:
|
||||
"""构建按 action 分支且可由外部 MCP Client 直接发现的输入合同。"""
|
||||
@@ -198,6 +238,15 @@ def _build_mcp_input_schema(
|
||||
branches = []
|
||||
for action in action_names:
|
||||
contract = actions[action].to_dict(action)
|
||||
properties = {
|
||||
"action": {"type": "string", "const": action},
|
||||
"arguments": _build_arguments_schema(action, actions[action]),
|
||||
}
|
||||
if selector_name:
|
||||
properties[selector_name] = {
|
||||
"type": "string",
|
||||
"description": selector_description or "",
|
||||
}
|
||||
branches.append(
|
||||
{
|
||||
"type": "object",
|
||||
@@ -206,27 +255,28 @@ def _build_mcp_input_schema(
|
||||
f"{contract['description']} Effect: {contract['effect']}. "
|
||||
f"Providers: {', '.join(contract['providers'])}."
|
||||
),
|
||||
"properties": {
|
||||
selector_name: {"type": "string", "description": selector_description},
|
||||
"action": {"type": "string", "const": action},
|
||||
"arguments": _build_arguments_schema(action, actions[action]),
|
||||
},
|
||||
"properties": properties,
|
||||
"required": ["action", "arguments"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
properties = {
|
||||
"action": {"type": "string", "enum": action_names},
|
||||
"arguments": {
|
||||
"type": "object",
|
||||
"description": "Arguments must match the oneOf branch selected by action.",
|
||||
},
|
||||
}
|
||||
if selector_name:
|
||||
properties[selector_name] = {
|
||||
"type": "string",
|
||||
"description": selector_description or "",
|
||||
}
|
||||
return {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": title,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
selector_name: {"type": "string", "description": selector_description},
|
||||
"action": {"type": "string", "enum": action_names},
|
||||
"arguments": {
|
||||
"type": "object",
|
||||
"description": "参数必须匹配所选 action 的 oneOf 分支。",
|
||||
},
|
||||
},
|
||||
"properties": properties,
|
||||
"required": ["action", "arguments"],
|
||||
"oneOf": branches,
|
||||
}
|
||||
@@ -283,18 +333,21 @@ class _ServiceOperationTool(MoviePilotTool):
|
||||
"""固定 Skill 脚本的 MCP-only 安全包装基类。"""
|
||||
|
||||
require_admin: bool = True
|
||||
tags: list[str] = [ToolTag.Admin]
|
||||
_relative_script: ClassVar[str]
|
||||
_selector_name: ClassVar[str]
|
||||
_selector_flag: ClassVar[str]
|
||||
_selector_name: ClassVar[Optional[str]]
|
||||
_selector_flag: ClassVar[Optional[str]]
|
||||
_blocking_bucket: ClassVar[str]
|
||||
|
||||
def get_mcp_input_schema(self) -> dict[str, Any]:
|
||||
"""返回保留 action 条件分支的完整 MCP JSON Schema。"""
|
||||
root_path = Path(get_runtime_setting("ROOT_PATH"))
|
||||
actions = _load_action_contracts(str(root_path / self._relative_script))
|
||||
selector_description = self.args_schema.model_json_schema()["properties"][self._selector_name][
|
||||
"description"
|
||||
]
|
||||
selector_description = None
|
||||
if self._selector_name:
|
||||
selector_description = self.args_schema.model_json_schema()["properties"][self._selector_name][
|
||||
"description"
|
||||
]
|
||||
return _build_mcp_input_schema(
|
||||
actions=actions,
|
||||
selector_name=self._selector_name,
|
||||
@@ -383,9 +436,135 @@ class MediaServerOperationTool(_ServiceOperationTool):
|
||||
)
|
||||
|
||||
|
||||
def _run_database_script(arguments: Dict[str, Any], *, root_path: Path) -> dict[str, Any]:
|
||||
"""不经 shell 调用数据库 Skill 脚本,并返回结构化结果。"""
|
||||
script_path = root_path / "skills/database-operation/scripts/mp-db.py"
|
||||
action = str(arguments.get("action") or "")
|
||||
action_arguments = arguments.get("arguments") or {}
|
||||
if not isinstance(action_arguments, dict):
|
||||
raise ValueError("数据库 action arguments 必须是对象")
|
||||
|
||||
contracts = _load_action_contracts(str(script_path))
|
||||
contract = contracts.get(action)
|
||||
if contract is None:
|
||||
raise ValueError(f"未知数据库 action: {action}")
|
||||
contract_data = contract.to_dict(action)
|
||||
declared_arguments = {item["name"]: item for item in contract_data["arguments"]}
|
||||
unknown_arguments = sorted(set(action_arguments) - set(declared_arguments))
|
||||
if unknown_arguments:
|
||||
raise ValueError(f"数据库 action 存在未知参数: {', '.join(unknown_arguments)}")
|
||||
missing_arguments = [
|
||||
name
|
||||
for name in contract_data["required_arguments"]
|
||||
if name not in action_arguments
|
||||
]
|
||||
if missing_arguments:
|
||||
raise ValueError(f"数据库 action 缺少必填参数: {', '.join(missing_arguments)}")
|
||||
if action in {"query", "write"}:
|
||||
has_sql = bool(action_arguments.get("sql"))
|
||||
has_file = bool(action_arguments.get("file"))
|
||||
if has_sql == has_file:
|
||||
raise ValueError("数据库 query/write 必须在 sql 与 file 中二选一")
|
||||
for field_name in ("sql", "file"):
|
||||
if field_name in action_arguments and (
|
||||
not isinstance(action_arguments[field_name], str)
|
||||
or not action_arguments[field_name].strip()
|
||||
):
|
||||
raise ValueError(f"数据库参数 {field_name} 必须是非空 string")
|
||||
if action == "schema" and (
|
||||
not isinstance(action_arguments.get("table_name"), str)
|
||||
or not action_arguments["table_name"].strip()
|
||||
):
|
||||
raise ValueError("数据库参数 table_name 必须是非空 string")
|
||||
if action == "query":
|
||||
limit = action_arguments.get("limit", 100)
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200:
|
||||
raise ValueError("数据库参数 limit 必须是 1 到 200 的 integer")
|
||||
write_flag = action_arguments.get("write", False)
|
||||
if not isinstance(write_flag, bool):
|
||||
raise ValueError("数据库参数 write 必须是 boolean")
|
||||
|
||||
command = [sys.executable, str(script_path), action]
|
||||
if action == "schema":
|
||||
command.append(str(action_arguments.get("table_name") or ""))
|
||||
elif action in {"query", "write"}:
|
||||
sql = action_arguments.get("sql")
|
||||
sql_file = action_arguments.get("file")
|
||||
if sql:
|
||||
command.append(str(sql))
|
||||
if sql_file:
|
||||
command.extend(["--file", str(sql_file)])
|
||||
if action == "query":
|
||||
command.extend(["--limit", str(action_arguments.get("limit", 100))])
|
||||
if action_arguments.get("write") is True:
|
||||
command.append("--write")
|
||||
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=root_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
error_text = completed.stderr.strip() or completed.stdout.strip() or "数据库脚本执行失败"
|
||||
return {"success": False, "error": "database_operation_failed", "message": error_text}
|
||||
return _parse_script_payload(completed.stdout)
|
||||
|
||||
|
||||
class DatabaseOperationTool(_ServiceOperationTool):
|
||||
"""外部 HTTP/MCP 调用 MoviePilot 数据库 Skill 的结构化工具。"""
|
||||
|
||||
name: str = "database_operation"
|
||||
description: str = (
|
||||
"Inspect or explicitly modify the configured MoviePilot database. The input schema "
|
||||
"contains exact branches for tables, schema, query, and write, including field types, "
|
||||
"defaults, mutually exclusive SQL sources, and safety rules."
|
||||
)
|
||||
tags: list[str] = [ToolTag.Admin]
|
||||
args_schema: Type[BaseModel] = DatabaseOperationInput
|
||||
_relative_script = "skills/database-operation/scripts/mp-db.py"
|
||||
_selector_name = None
|
||||
_selector_flag = None
|
||||
_blocking_bucket = "db"
|
||||
|
||||
def get_mcp_input_schema(self) -> dict[str, Any]:
|
||||
"""返回数据库 action 的完整 MCP JSON Schema。"""
|
||||
root_path = Path(get_runtime_setting("ROOT_PATH"))
|
||||
actions = _load_action_contracts(str(root_path / self._relative_script))
|
||||
return _build_mcp_input_schema(
|
||||
actions=actions,
|
||||
selector_name=None,
|
||||
selector_description=None,
|
||||
title=self.name,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
action: str,
|
||||
arguments: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
执行一次数据库 Skill 操作。
|
||||
|
||||
:param action: tables、schema、query 或 write
|
||||
:param arguments: 当前 action 的结构化参数
|
||||
:return: 结构化数据库结果
|
||||
"""
|
||||
payload = await self.run_blocking(
|
||||
self._blocking_bucket,
|
||||
_run_database_script,
|
||||
root_path=Path(get_runtime_setting("ROOT_PATH")),
|
||||
arguments={"action": action, "arguments": arguments or {}},
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DownloaderOperationInput",
|
||||
"DownloaderOperationTool",
|
||||
"DatabaseOperationInput",
|
||||
"DatabaseOperationTool",
|
||||
"MediaServerOperationInput",
|
||||
"MediaServerOperationTool",
|
||||
]
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated, Any, Dict, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import Depends, Header, HTTPException, Security
|
||||
from fastapi import Depends, Header, HTTPException, Query, Security
|
||||
from starlette import status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.application.plugin.folders import (
|
||||
remove_plugin_from_folders,
|
||||
)
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
from app.application.plugin.management import get_plugin_snapshot
|
||||
from app.application.plugin.management import get_plugin_snapshot, search_plugin_candidates
|
||||
from app.application.plugin.rating import (
|
||||
PluginNotInstalledError,
|
||||
get_plugin_rating_service,
|
||||
@@ -183,14 +183,19 @@ async def all_plugins(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
state: Optional[str] = "all",
|
||||
force: bool = False,
|
||||
query: Optional[str] = None,
|
||||
max_results: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> List[_SchemaPlugin]:
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
查询插件清单,并支持 Agent 使用关键字和有界结果完成精确选择。
|
||||
"""
|
||||
return await get_plugin_catalog_query().query(
|
||||
plugins = await get_plugin_catalog_query().query(
|
||||
state=state or "all",
|
||||
force=force,
|
||||
)
|
||||
if query:
|
||||
plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)]
|
||||
return plugins[:max_results]
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
@@ -775,11 +780,14 @@ async def get_plugin_folders(
|
||||
|
||||
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None])
|
||||
async def save_plugin_folders(folders: dict, _: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any:
|
||||
async def save_plugin_folders(
|
||||
folders: _SchemaPluginFoldersData,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""
|
||||
保存插件文件夹分组配置
|
||||
"""
|
||||
result = await get_plugin_folder_service().save(folders)
|
||||
result = await get_plugin_folder_service().save(folders.root)
|
||||
return _SchemaResponse(success=result.success, message=result.message)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""过滤规则和规则组管理 API。"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Query
|
||||
|
||||
from app.api.context import get_host_runtime
|
||||
from app.api.dependencies.auth import get_current_active_superuser_async
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_active_superuser_async,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.api.principal import ApiPrincipal
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.application.filtering import FilterRuleService
|
||||
@@ -43,8 +46,16 @@ def _service(runtime: HostRuntime) -> FilterRuleService:
|
||||
response_model=_SchemaResponse[_SchemaJsonObject],
|
||||
)
|
||||
async def query_builtin_rules(
|
||||
rule_ids: Optional[list[str]] = None,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
rule_ids: Annotated[
|
||||
Optional[list[str]],
|
||||
Query(
|
||||
description=(
|
||||
"Exact built-in rule IDs to return. Repeat rule_ids in the query string; "
|
||||
"omit it to list every built-in rule."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
_: ApiPrincipal = Depends(get_current_active_user_async),
|
||||
) -> _SchemaResponse[Any]:
|
||||
"""返回内置规则及规则串语法。"""
|
||||
return _SchemaResponse(
|
||||
@@ -59,9 +70,17 @@ async def query_builtin_rules(
|
||||
response_model=_SchemaResponse[_SchemaJsonObject],
|
||||
)
|
||||
async def query_custom_rules(
|
||||
rule_ids: Optional[list[str]] = None,
|
||||
rule_ids: Annotated[
|
||||
Optional[list[str]],
|
||||
Query(
|
||||
description=(
|
||||
"Exact custom rule IDs to return. Repeat rule_ids in the query string; "
|
||||
"omit it to list every custom rule."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
include_group_refs: bool = True,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
_: ApiPrincipal = Depends(get_current_active_user_async),
|
||||
) -> _SchemaResponse[Any]:
|
||||
"""返回自定义规则和可选规则组引用。"""
|
||||
return _SchemaResponse(
|
||||
@@ -79,9 +98,17 @@ async def query_custom_rules(
|
||||
response_model=_SchemaResponse[_SchemaJsonObject],
|
||||
)
|
||||
async def query_rule_groups(
|
||||
group_names: Optional[list[str]] = None,
|
||||
group_names: Annotated[
|
||||
Optional[list[str]],
|
||||
Query(
|
||||
description=(
|
||||
"Exact rule-group names to return. Repeat group_names in the query string; "
|
||||
"omit it to list every group."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
include_usage: bool = True,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
_: ApiPrincipal = Depends(get_current_active_user_async),
|
||||
runtime: HostRuntime = Depends(get_host_runtime),
|
||||
) -> _SchemaResponse[Any]:
|
||||
"""返回规则组、解析层级和可选引用位置。"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated, Any, Dict, List, Optional
|
||||
from typing import Annotated, Any, Dict, List, Literal, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.api.dependencies.auth import (
|
||||
get_current_active_manage_user_async,
|
||||
get_current_active_superuser,
|
||||
get_current_active_superuser_async,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.api.dependencies.site import (
|
||||
get_site_mutation_command,
|
||||
@@ -38,6 +39,7 @@ from app.schemas.site import SiteCategory as _SchemaSiteCategory
|
||||
from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate
|
||||
from app.schemas.site import SiteIconData as _SchemaSiteIconData
|
||||
from app.schemas.site import SiteMappingData as _SchemaSiteMappingData
|
||||
from app.schemas.site import SitePriorityUpdate as _SchemaSitePriorityUpdate
|
||||
from app.schemas.site import SiteStatistic as _SchemaSiteStatistic
|
||||
from app.schemas.site import SiteUserData as _SchemaSiteUserData
|
||||
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
|
||||
@@ -48,6 +50,39 @@ from app.schemas.workflow import Site as _SchemaSite
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _project_agent_site(site: Any, *, include_secrets: bool) -> dict[str, JsonData]:
|
||||
"""构造旧 Agent 查询语义的站点安全投影,普通用户不返回认证凭据。"""
|
||||
projected: dict[str, JsonData] = {
|
||||
"id": site.id,
|
||||
"name": site.name,
|
||||
"domain": site.domain,
|
||||
"url": site.url,
|
||||
"pri": site.pri,
|
||||
"is_active": site.is_active,
|
||||
"downloader": site.downloader,
|
||||
"ua": site.ua,
|
||||
"proxy": site.proxy,
|
||||
"filter": site.filter,
|
||||
"render": site.render,
|
||||
"public": site.public,
|
||||
"note": site.note,
|
||||
"limit_interval": site.limit_interval,
|
||||
"limit_count": site.limit_count,
|
||||
"limit_seconds": site.limit_seconds,
|
||||
"timeout": site.timeout,
|
||||
}
|
||||
if include_secrets:
|
||||
projected.update(
|
||||
{
|
||||
"rss": site.rss,
|
||||
"cookie": site.cookie,
|
||||
"apikey": site.apikey,
|
||||
"token": site.token,
|
||||
}
|
||||
)
|
||||
return projected
|
||||
|
||||
|
||||
def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
|
||||
"""
|
||||
判断站点索引器是否支持指定媒体类型。
|
||||
@@ -95,6 +130,36 @@ async def read_sites(
|
||||
return await query.list_ordered()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent",
|
||||
summary="查询 Agent 可用站点",
|
||||
response_model=List[_SchemaJsonObject],
|
||||
)
|
||||
async def read_agent_sites(
|
||||
status: Literal["active", "inactive", "all"] = "all",
|
||||
name: Optional[str] = None,
|
||||
query: SiteQueryService = Depends(get_site_query_service),
|
||||
current_user: Any = Depends(get_current_active_user_async),
|
||||
) -> List[dict[str, JsonData]]:
|
||||
"""按旧 Agent 过滤语义返回站点,非超级管理员自动剔除认证字段。"""
|
||||
sites = await query.list_ordered()
|
||||
results = []
|
||||
for site in sites:
|
||||
if status == "active" and not site.is_active:
|
||||
continue
|
||||
if status == "inactive" and site.is_active:
|
||||
continue
|
||||
if name and name.lower() not in (site.name or "").lower():
|
||||
continue
|
||||
results.append(
|
||||
_project_agent_site(
|
||||
site,
|
||||
include_secrets=bool(current_user.is_superuser),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get(
|
||||
"/media/{media_type}",
|
||||
summary="按媒体类型获取可搜索站点",
|
||||
@@ -208,14 +273,14 @@ async def reset(
|
||||
"/priorities", summary="批量更新站点优先级", response_model=_SchemaResponse[None]
|
||||
)
|
||||
async def update_sites_priority(
|
||||
priorities: List[Dict[str, JsonData]],
|
||||
priorities: List[_SchemaSitePriorityUpdate],
|
||||
command: SiteMutationCommand = Depends(get_site_mutation_command),
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
批量更新站点优先级
|
||||
"""
|
||||
result = await command.update_priorities(priorities)
|
||||
result = await command.update_priorities([priority.model_dump() for priority in priorities])
|
||||
return _SchemaResponse(success=result.success, message=result.message)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from starlette.responses import FileResponse, Response
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_active_manage_user,
|
||||
get_current_active_superuser,
|
||||
get_current_active_user,
|
||||
)
|
||||
from app.api.principal import ApiPrincipal
|
||||
from app.api.response import ResponseAPIRouter
|
||||
@@ -123,6 +124,16 @@ def list_files(
|
||||
:param _: token
|
||||
:return: 所有目录和文件
|
||||
"""
|
||||
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
|
||||
|
||||
|
||||
def _list_files(
|
||||
*,
|
||||
fileitem: _SchemaFileItem,
|
||||
sort: Optional[str],
|
||||
keyword: Optional[str],
|
||||
) -> List[_SchemaFileItem]:
|
||||
"""执行目录查询、通配符过滤和稳定排序,供管理端与 Agent 安全入口复用。"""
|
||||
file_list = StorageChain().list_files(fileitem)
|
||||
if file_list:
|
||||
if keyword:
|
||||
@@ -132,7 +143,22 @@ def list_files(
|
||||
file_list.sort(key=lambda x: text_tools.natural_sort_key(x.name or ""))
|
||||
else:
|
||||
file_list.sort(key=lambda x: x.modify_time or -math.inf, reverse=True)
|
||||
return file_list
|
||||
return file_list or []
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/list",
|
||||
summary="查询 Agent 可用目录和文件",
|
||||
response_model=List[_SchemaFileItem],
|
||||
)
|
||||
def list_agent_files(
|
||||
fileitem: _SchemaFileItem,
|
||||
sort: Optional[str] = "updated_at",
|
||||
keyword: Optional[str] = None,
|
||||
_: Any = Depends(get_current_active_user),
|
||||
) -> List[_SchemaFileItem]:
|
||||
"""保留旧 Agent 普通用户目录读取能力,不开放创建、改名或删除入口。"""
|
||||
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("/mkdir", summary="创建目录", response_model=_SchemaResponse[None])
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Annotated, Any, Optional, Union
|
||||
|
||||
import anyio
|
||||
import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用
|
||||
from fastapi import Body, Depends, Header, HTTPException, Request, Response
|
||||
from fastapi import Body, Depends, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token
|
||||
@@ -620,15 +620,43 @@ async def set_setting(
|
||||
|
||||
@router.get( # type: ignore[misc]
|
||||
"/settings",
|
||||
summary="统一查询系统设置",
|
||||
summary="Discover or read registered system settings",
|
||||
response_model=_SchemaResponse[_SchemaJsonObject],
|
||||
)
|
||||
async def query_settings(
|
||||
setting_key: Optional[str] = None,
|
||||
group: Optional[str] = "all",
|
||||
keyword: Optional[str] = None,
|
||||
include_values: Optional[bool] = None,
|
||||
show_secrets: bool = False,
|
||||
setting_key: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
description=(
|
||||
"Exact setting key. Accepts Settings field names such as APP_DOMAIN or LLM_MODEL, "
|
||||
"SystemConfigKey values or enum names such as Downloaders or MediaServers, and "
|
||||
"aliases that resolve to one unique setting. Omit it to discover settings."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
group: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
description=(
|
||||
"Discovery group used when setting_key is omitted. Supported groups are all, settings, systemconfig, downloaders, "
|
||||
"media_servers, notifications, notification_switches, storages, directories, "
|
||||
"search_sites, subscribe_sites, site_auth, ai_agent, filter_rules, "
|
||||
"subscribe_defaults, plugins, customization, transfer, scraping, and misc."
|
||||
)
|
||||
),
|
||||
] = "all",
|
||||
keyword: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Case-insensitive substring used to discover matching keys, groups, or labels."),
|
||||
] = None,
|
||||
include_values: Annotated[
|
||||
Optional[bool],
|
||||
Query(description="Return full values. Defaults to true for one exact key and false for discovery results."),
|
||||
] = None,
|
||||
show_secrets: Annotated[
|
||||
bool,
|
||||
Query(description="Return unredacted secret values. Defaults to false and remains confirmation-protected."),
|
||||
] = False,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
runtime: HostRuntime = Depends(get_host_runtime),
|
||||
) -> _SchemaResponse[Any]:
|
||||
@@ -652,7 +680,7 @@ async def query_settings(
|
||||
|
||||
@router.post( # type: ignore[misc]
|
||||
"/settings",
|
||||
summary="统一更新系统设置",
|
||||
summary="Update one registered system setting",
|
||||
response_model=_SchemaResponse[_SchemaJsonObject],
|
||||
)
|
||||
async def update_settings(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Literal, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.application.workflow import (
|
||||
get_workflow_manager,
|
||||
)
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.schemas.common import JsonObject as _SchemaJsonObject
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.types import EVENT_TYPE_NAMES, EventType
|
||||
from app.schemas.workflow import NameValueOption as _SchemaNameValueOption
|
||||
@@ -42,6 +43,47 @@ async def list_workflows(
|
||||
return await query.list()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent",
|
||||
summary="查询 Agent 可用工作流",
|
||||
response_model=List[_SchemaJsonObject],
|
||||
)
|
||||
async def list_agent_workflows(
|
||||
state: Literal["W", "R", "P", "S", "F", "all"] = "all",
|
||||
name: Optional[str] = None,
|
||||
trigger_type: Literal["timer", "event", "manual", "all"] = "all",
|
||||
query: WorkflowQueryService = Depends(get_workflow_query_service),
|
||||
_: Any = Depends(get_current_active_manage_user_async),
|
||||
) -> List[dict[str, Any]]:
|
||||
"""按旧 Agent 过滤与字段投影返回工作流列表,避免输出完整动作上下文。"""
|
||||
workflows = await query.list()
|
||||
results = []
|
||||
for workflow in workflows:
|
||||
if state != "all" and workflow.state != state:
|
||||
continue
|
||||
normalized_trigger = workflow.trigger_type or "timer"
|
||||
if trigger_type != "all" and normalized_trigger != trigger_type:
|
||||
continue
|
||||
if name and name.lower() not in (workflow.name or "").lower():
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"description": workflow.description,
|
||||
"trigger_type": normalized_trigger,
|
||||
"state": workflow.state,
|
||||
"run_count": workflow.run_count,
|
||||
"timer": workflow.timer,
|
||||
"event_type": workflow.event_type,
|
||||
"add_time": workflow.add_time,
|
||||
"last_time": workflow.last_time,
|
||||
"current_action": workflow.current_action,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/", summary="创建工作流", response_model=_SchemaResponse[None])
|
||||
async def create_workflow(
|
||||
workflow: _SchemaWorkflow,
|
||||
@@ -235,14 +277,17 @@ async def get_workflow(
|
||||
|
||||
@router.put("/{workflow_id}", summary="更新工作流", response_model=_SchemaResponse[None])
|
||||
def update_workflow(
|
||||
workflow_id: int,
|
||||
workflow: _SchemaWorkflow,
|
||||
command: WorkflowMutationCommand = Depends(get_workflow_mutation_command),
|
||||
_: Any = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
更新工作流
|
||||
更新工作流,路径 ID 是本次更新的唯一目标。
|
||||
"""
|
||||
result = command.update(workflow.model_dump())
|
||||
workflow_data = workflow.model_dump()
|
||||
workflow_data["id"] = workflow_id
|
||||
result = command.update(workflow_data)
|
||||
return _SchemaResponse(success=result.success, message=result.message)
|
||||
|
||||
|
||||
|
||||
+75
-34
@@ -25,141 +25,142 @@ class SettingSpec:
|
||||
source: str
|
||||
group: str
|
||||
label: str
|
||||
declared_type: str = "unknown"
|
||||
systemconfig_key: Optional[SystemConfigKey] = None
|
||||
|
||||
|
||||
SYSTEMCONFIG_SETTING_METADATA = {
|
||||
SystemConfigKey.Downloaders.value: {
|
||||
"group": "downloaders",
|
||||
"label": "下载器配置",
|
||||
"label": "Downloader configurations",
|
||||
},
|
||||
SystemConfigKey.MediaServers.value: {
|
||||
"group": "media_servers",
|
||||
"label": "媒体服务器配置",
|
||||
"label": "Media-server configurations",
|
||||
},
|
||||
SystemConfigKey.Notifications.value: {
|
||||
"group": "notifications",
|
||||
"label": "消息通知配置",
|
||||
"label": "Notification-channel configurations",
|
||||
},
|
||||
SystemConfigKey.NotificationSwitchs.value: {
|
||||
"group": "notification_switches",
|
||||
"label": "通知场景开关",
|
||||
"label": "Notification-scenario switches",
|
||||
},
|
||||
SystemConfigKey.Directories.value: {
|
||||
"group": "directories",
|
||||
"label": "目录配置",
|
||||
"label": "Directory configurations",
|
||||
},
|
||||
SystemConfigKey.Storages.value: {
|
||||
"group": "storages",
|
||||
"label": "存储配置",
|
||||
"label": "Storage configurations",
|
||||
},
|
||||
SystemConfigKey.IndexerSites.value: {
|
||||
"group": "search_sites",
|
||||
"label": "搜索站点范围",
|
||||
"label": "Search-site scope",
|
||||
},
|
||||
SystemConfigKey.RssSites.value: {
|
||||
"group": "subscribe_sites",
|
||||
"label": "订阅站点范围",
|
||||
"label": "Subscription-site scope",
|
||||
},
|
||||
SystemConfigKey.UserSiteAuthParams.value: {
|
||||
"group": "site_auth",
|
||||
"label": "站点认证参数",
|
||||
"label": "Site authentication parameters",
|
||||
},
|
||||
SystemConfigKey.AIAgentConfig.value: {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体配置",
|
||||
"label": "AI Agent configuration",
|
||||
},
|
||||
SystemConfigKey.AIAgentMcpServers.value: {
|
||||
"group": "ai_agent",
|
||||
"label": "AI 智能体外部 MCP 服务器",
|
||||
"label": "AI Agent external MCP servers",
|
||||
},
|
||||
SystemConfigKey.CustomIdentifiers.value: {
|
||||
"group": "custom_identifiers",
|
||||
"label": "自定义识别词",
|
||||
"label": "Custom recognition identifiers",
|
||||
},
|
||||
SystemConfigKey.EpisodeFormatRuleTable.value: {
|
||||
"group": "transfer",
|
||||
"label": "集数定位规则词表",
|
||||
"label": "Episode-position rule table",
|
||||
},
|
||||
SystemConfigKey.CustomReleaseGroups.value: {
|
||||
"group": "customization",
|
||||
"label": "自定义制作组/字幕组",
|
||||
"label": "Custom release and subtitle groups",
|
||||
},
|
||||
SystemConfigKey.Customization.value: {
|
||||
"group": "customization",
|
||||
"label": "自定义占位符",
|
||||
"label": "Custom placeholders",
|
||||
},
|
||||
SystemConfigKey.TransferExcludeWords.value: {
|
||||
"group": "transfer",
|
||||
"label": "整理屏蔽词",
|
||||
"label": "Transfer exclusion words",
|
||||
},
|
||||
SystemConfigKey.TorrentsPriority.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "种子优先级规则",
|
||||
"label": "Torrent-priority rules",
|
||||
},
|
||||
SystemConfigKey.CustomFilterRules.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "用户自定义规则",
|
||||
"label": "User-defined filter rules",
|
||||
},
|
||||
SystemConfigKey.UserFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "用户规则组",
|
||||
"label": "User filter-rule groups",
|
||||
},
|
||||
SystemConfigKey.SearchFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "搜索默认过滤规则组",
|
||||
"label": "Default search filter-rule groups",
|
||||
},
|
||||
SystemConfigKey.SubscribeFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "订阅默认过滤规则组",
|
||||
"label": "Default subscription filter-rule groups",
|
||||
},
|
||||
SystemConfigKey.BestVersionFilterRuleGroups.value: {
|
||||
"group": "filter_rules",
|
||||
"label": "洗版默认过滤规则组",
|
||||
"label": "Default upgrade filter-rule groups",
|
||||
},
|
||||
SystemConfigKey.SubscribeDefaultParams.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "订阅默认参数",
|
||||
"label": "Default subscription parameters",
|
||||
},
|
||||
SystemConfigKey.DefaultMovieSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认电影订阅规则",
|
||||
"label": "Default movie subscription rules",
|
||||
},
|
||||
SystemConfigKey.DefaultTvSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认电视剧订阅规则",
|
||||
"label": "Default TV subscription rules",
|
||||
},
|
||||
SystemConfigKey.DefaultMusicSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认音乐订阅规则",
|
||||
"label": "Default music subscription rules",
|
||||
},
|
||||
SystemConfigKey.UserInstalledPlugins.value: {
|
||||
"group": "plugins",
|
||||
"label": "已安装插件列表",
|
||||
"label": "Installed plugin list",
|
||||
},
|
||||
SystemConfigKey.PluginFolders.value: {
|
||||
"group": "plugins",
|
||||
"label": "插件文件夹分组配置",
|
||||
"label": "Plugin folder grouping",
|
||||
},
|
||||
SystemConfigKey.PluginInstallReport.value: {
|
||||
"group": "plugins",
|
||||
"label": "插件安装统计",
|
||||
"label": "Plugin installation report",
|
||||
},
|
||||
SystemConfigKey.NotificationSendTime.value: {
|
||||
"group": "notifications",
|
||||
"label": "通知发送时间",
|
||||
"label": "Notification delivery time",
|
||||
},
|
||||
SystemConfigKey.NotificationTemplates.value: {
|
||||
"group": "notifications",
|
||||
"label": "通知模板",
|
||||
"label": "Notification templates",
|
||||
},
|
||||
SystemConfigKey.ScrapingSwitchs.value: {
|
||||
"group": "scraping",
|
||||
"label": "刮削开关设置",
|
||||
"label": "Metadata-scraping switches",
|
||||
},
|
||||
SystemConfigKey.FollowSubscribers.value: {
|
||||
"group": "subscribe_sites",
|
||||
"label": "Follow 订阅分享者",
|
||||
"label": "Followed subscription publishers",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -258,6 +259,12 @@ def _resolve_core_setting_group(key: str) -> str:
|
||||
return "settings"
|
||||
|
||||
|
||||
def _format_declared_type(annotation: Any) -> str:
|
||||
"""把 Pydantic 字段注解转换为稳定且便于 Agent 阅读的类型文本。"""
|
||||
rendered = str(annotation).replace("typing.", "")
|
||||
return rendered.replace("<class '", "").replace("'>", "")
|
||||
|
||||
|
||||
def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
|
||||
core_specs = {
|
||||
key: SettingSpec(
|
||||
@@ -265,8 +272,9 @@ def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
|
||||
source="settings",
|
||||
group=_resolve_core_setting_group(key),
|
||||
label=key,
|
||||
declared_type=_format_declared_type(field.annotation),
|
||||
)
|
||||
for key in Settings.model_fields.keys()
|
||||
for key, field in Settings.model_fields.items()
|
||||
}
|
||||
system_specs = {}
|
||||
for item in SystemConfigKey:
|
||||
@@ -276,6 +284,11 @@ def _build_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec]]:
|
||||
source="systemconfig",
|
||||
group=metadata.get("group", "misc"),
|
||||
label=metadata.get("label", item.value),
|
||||
declared_type=(
|
||||
"list[object]"
|
||||
if item.value in LIST_ITEM_MATCH_FIELD_DEFAULTS
|
||||
else "JSON-compatible value"
|
||||
),
|
||||
systemconfig_key=item,
|
||||
)
|
||||
return core_specs, system_specs
|
||||
@@ -429,6 +442,29 @@ class SystemSettingsService:
|
||||
summary["value_preview"] = value
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _definition(spec: SettingSpec, value: Any, *, sensitive: bool) -> dict[str, Any]:
|
||||
"""返回当前设置值形状、持久化位置和允许的更新操作。"""
|
||||
default_match_field = get_default_list_match_field(spec.key)
|
||||
if isinstance(value, list) or (value is None and default_match_field):
|
||||
update_operations = ["replace", "upsert_list_item", "remove_list_item"]
|
||||
value_shape = "list"
|
||||
elif isinstance(value, dict):
|
||||
update_operations = ["replace", "merge_dict"]
|
||||
value_shape = "object"
|
||||
else:
|
||||
update_operations = ["replace"]
|
||||
value_shape = type(value).__name__ if value is not None else "unknown"
|
||||
return {
|
||||
"declared_type": spec.declared_type,
|
||||
"value_shape": value_shape,
|
||||
"nullable": value is None,
|
||||
"sensitive": sensitive,
|
||||
"update_operations": update_operations,
|
||||
"default_match_field": default_match_field,
|
||||
"persistence": "app.env" if spec.source == "settings" else "database:systemconfig",
|
||||
}
|
||||
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
@@ -466,6 +502,11 @@ class SystemSettingsService:
|
||||
"source": spec.source,
|
||||
"group": spec.group,
|
||||
"label": spec.label,
|
||||
"definition": self._definition(
|
||||
spec,
|
||||
value,
|
||||
sensitive=should_redact_setting(spec, value),
|
||||
),
|
||||
**self._summarize(response_value, redacted=redacted),
|
||||
}
|
||||
if should_include_values:
|
||||
|
||||
@@ -359,6 +359,7 @@ SCHEMA_EXPORTS = {
|
||||
'SiteEventData': ('app.schemas.event', 'SiteEventData'),
|
||||
'SiteIconData': ('app.schemas.site', 'SiteIconData'),
|
||||
'SiteMappingData': ('app.schemas.site', 'SiteMappingData'),
|
||||
'SitePriorityUpdate': ('app.schemas.site', 'SitePriorityUpdate'),
|
||||
'SiteStatistic': ('app.schemas.site', 'SiteStatistic'),
|
||||
'SiteUnreadMessage': ('app.schemas.site', 'SiteUnreadMessage'),
|
||||
'SiteUserData': ('app.schemas.site', 'SiteUserData'),
|
||||
|
||||
+14
-7
@@ -405,19 +405,26 @@ class PluginFolderConfigData(BaseModel):
|
||||
"""新版插件文件夹配置(对象格式,含展示配置与插件列表)。"""
|
||||
|
||||
# 文件夹内插件 ID 列表
|
||||
plugins: List[str] = Field(default_factory=list)
|
||||
plugins: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered installed plugin IDs assigned to this folder.",
|
||||
)
|
||||
# 文件夹排序值
|
||||
order: Optional[int] = None
|
||||
order: Optional[int] = Field(default=None, description="Folder display-order value.")
|
||||
# 文件夹图标
|
||||
icon: Optional[str] = None
|
||||
icon: Optional[str] = Field(default=None, description="Optional folder icon name.")
|
||||
# 文件夹颜色
|
||||
color: Optional[str] = None
|
||||
color: Optional[str] = Field(default=None, description="Optional folder foreground color.")
|
||||
# 文件夹渐变
|
||||
gradient: Optional[str] = None
|
||||
gradient: Optional[str] = Field(default=None, description="Optional folder gradient definition.")
|
||||
# 文件夹背景
|
||||
background: Optional[str] = None
|
||||
background: Optional[str] = Field(default=None, description="Optional folder background color or style.")
|
||||
# 是否显示图标(前端字段为驼峰命名)
|
||||
show_icon: Optional[bool] = Field(default=None, alias="showIcon")
|
||||
show_icon: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="showIcon",
|
||||
description="Whether the frontend should display the folder icon.",
|
||||
)
|
||||
|
||||
|
||||
class PluginFoldersData(RootModel[Dict[str, Union[List[str], PluginFolderConfigData]]]):
|
||||
|
||||
+9
-3
@@ -1,10 +1,9 @@
|
||||
from typing import Optional, Union, Dict
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, RootModel
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
SiteUnreadMessage = Union[
|
||||
tuple[Optional[str], Optional[str], Optional[str]],
|
||||
tuple[Optional[str], Optional[str], Optional[str], Optional[str]],
|
||||
@@ -61,6 +60,13 @@ class Site(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SitePriorityUpdate(BaseModel):
|
||||
"""站点批量优先级更新项。"""
|
||||
|
||||
id: int = Field(..., description="Persistent site ID returned by site.list.")
|
||||
pri: int = Field(..., description="Replacement site search priority value.")
|
||||
|
||||
|
||||
class SiteStatistic(BaseModel):
|
||||
"""单个站点的访问成功率与耗时统计。"""
|
||||
|
||||
|
||||
+36
-6
@@ -149,17 +149,47 @@ class SystemEnvironmentUpdateData(BaseModel):
|
||||
class SystemSettingsUpdateRequest(BaseModel): # type: ignore[misc]
|
||||
"""统一系统设置更新请求。"""
|
||||
|
||||
setting_key: str
|
||||
value: Any = None
|
||||
setting_key: str = Field(
|
||||
description=(
|
||||
"Exact setting key. Accepts a Settings field name, a SystemConfigKey value or enum name, "
|
||||
"or an alias that resolves to one unique setting. Call config.system.get with group or "
|
||||
"keyword first when the key is unknown."
|
||||
)
|
||||
)
|
||||
value: Any = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"New value or list item. For replace, send the complete value. For merge_dict, send the "
|
||||
"object fragment to merge. For upsert_list_item or remove_list_item, send one object or scalar item."
|
||||
),
|
||||
)
|
||||
operation: Literal[
|
||||
"replace",
|
||||
"merge_dict",
|
||||
"upsert_list_item",
|
||||
"remove_list_item",
|
||||
] = "replace"
|
||||
remove_keys: list[str] = Field(default_factory=list)
|
||||
match_field: Optional[str] = None
|
||||
match_value: Any = None
|
||||
] = Field(
|
||||
default="replace",
|
||||
description=(
|
||||
"replace overwrites the complete value; merge_dict shallow-merges an object; "
|
||||
"upsert_list_item inserts or replaces one matched list item; remove_list_item removes one matched list item."
|
||||
),
|
||||
)
|
||||
remove_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Object keys to remove after merge_dict applies the supplied value.",
|
||||
)
|
||||
match_field: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Object field used to match a list item. Downloaders, MediaServers, Notifications, Directories, "
|
||||
"and Storages default to name; NotificationSwitchs defaults to type. Supply it for other object lists."
|
||||
),
|
||||
)
|
||||
match_value: Any = Field(
|
||||
default=None,
|
||||
description="Value compared against match_field. If omitted, use value[match_field]; scalar lists use value directly.",
|
||||
)
|
||||
|
||||
|
||||
class CustomIdentifiersUpdateRequest(BaseModel): # type: ignore[misc]
|
||||
|
||||
@@ -754,8 +754,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 917 |
|
||||
| 内部导入边 | 7,671 |
|
||||
| Python 模块 | 919 |
|
||||
| 内部导入边 | 7,680 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
# MoviePilot Agent API Surface Audit
|
||||
|
||||
> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.
|
||||
> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.
|
||||
|
||||
## Result
|
||||
|
||||
- OpenAPI HTTP operations: **375**
|
||||
- Stable `moviepilot_api` operations: **203**
|
||||
- Exact HTTP routes used by the gateway: **201**
|
||||
- OpenAPI routes matched directly by the gateway: **200**
|
||||
- Bounded dynamic gateway routes: **1**
|
||||
- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.
|
||||
- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.
|
||||
|
||||
## Dispositions
|
||||
|
||||
| disposition | count | meaning |
|
||||
| :--- | ---: | :--- |
|
||||
| `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. |
|
||||
| `consolidated` | 72 | Source/UI route represented by a stable aggregate Agent operation. |
|
||||
| `gateway` | 200 | Approved structured MoviePilot Agent operation. |
|
||||
| `provider-skill` | 11 | Low-level downloader or media-server capability owned by a provider Skill. |
|
||||
| `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. |
|
||||
| `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. |
|
||||
| `ui_presentation` | 5 | Frontend or plugin-rendered presentation contract. |
|
||||
|
||||
## Bounded Dynamic Routes
|
||||
|
||||
| method | route template | operations | constraint |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/{source}/person/credits/{person_id}` | media.person.credits | The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route. |
|
||||
|
||||
## Complete Route Inventory
|
||||
|
||||
| method | path | tags | disposition | owner / operation | summary |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/anilist/credits/{anilist_id}` | anilist | `consolidated` | moviepilot-api | 查询 AniList 配音演员 |
|
||||
| `GET` | `/api/v1/anilist/discover` | anilist | `consolidated` | moviepilot-api | 探索 AniList 动画 |
|
||||
| `GET` | `/api/v1/anilist/person/credits/{person_id}` | anilist | `consolidated` | moviepilot-api | 查询 AniList 人物作品 |
|
||||
| `GET` | `/api/v1/anilist/person/{person_id}` | anilist | `consolidated` | moviepilot-api | 查询 AniList 人物详情 |
|
||||
| `GET` | `/api/v1/anilist/popular-this-season` | anilist | `consolidated` | moviepilot-api | 查询 AniList 本季热门榜 |
|
||||
| `GET` | `/api/v1/anilist/recommend/{anilist_id}` | anilist | `consolidated` | moviepilot-api | 查询 AniList 相关推荐 |
|
||||
| `GET` | `/api/v1/anilist/trending` | anilist | `consolidated` | moviepilot-api | 查询 AniList 当前趋势榜 |
|
||||
| `GET` | `/api/v1/anilist/{anilist_id}` | anilist | `consolidated` | moviepilot-api | 查询 AniList 动画详情 |
|
||||
| `POST` | `/api/v1/anthropic/v1/messages` | anthropic | `transport_or_identity` | host-runtime | Anthropic compatible messages |
|
||||
| `POST` | `/api/v1/auth/exchange` | auth | `transport_or_identity` | host-runtime | 兑换插件认证登录票据 |
|
||||
| `GET` | `/api/v1/auth/providers` | auth | `transport_or_identity` | host-runtime | 查询登录认证提供方 |
|
||||
| `GET` | `/api/v1/bangumi/credits/{bangumiid}` | bangumi | `consolidated` | moviepilot-api | 查询Bangumi演职员表 |
|
||||
| `GET` | `/api/v1/bangumi/person/credits/{person_id}` | bangumi | `consolidated` | moviepilot-api | 人物参演作品 |
|
||||
| `GET` | `/api/v1/bangumi/person/{person_id}` | bangumi | `consolidated` | moviepilot-api | 人物详情 |
|
||||
| `GET` | `/api/v1/bangumi/recommend/{bangumiid}` | bangumi | `consolidated` | moviepilot-api | 查询Bangumi推荐 |
|
||||
| `GET` | `/api/v1/bangumi/{bangumiid}` | bangumi | `consolidated` | moviepilot-api | 查询Bangumi详情 |
|
||||
| `GET` | `/api/v1/dashboard/cpu` | dashboard | `gateway` | dashboard.cpu | 获取当前CPU使用率 |
|
||||
| `GET` | `/api/v1/dashboard/cpu2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 获取当前CPU使用率(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/downloader` | dashboard | `gateway` | dashboard.downloader | 下载器信息 |
|
||||
| `GET` | `/api/v1/dashboard/downloader2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 下载器信息(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/memory` | dashboard | `gateway` | dashboard.memory | 获取当前应用与系统内存信息 |
|
||||
| `GET` | `/api/v1/dashboard/memory2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 获取当前应用与系统内存信息(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/network` | dashboard | `gateway` | dashboard.network | 获取当前网络流量 |
|
||||
| `GET` | `/api/v1/dashboard/network2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 获取当前网络流量(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/processes` | dashboard | `gateway` | dashboard.processes | 进程信息 |
|
||||
| `GET` | `/api/v1/dashboard/schedule` | dashboard | `gateway` | scheduler.list | 后台服务 |
|
||||
| `GET` | `/api/v1/dashboard/schedule/{job_id}/progress` | dashboard | `gateway` | scheduler.progress | 后台服务进度 |
|
||||
| `GET` | `/api/v1/dashboard/schedule2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 后台服务(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/schedule2/{job_id}/progress` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 后台服务进度(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/statistic` | dashboard | `gateway` | dashboard.media.statistics | 媒体数量统计 |
|
||||
| `GET` | `/api/v1/dashboard/statistic2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 媒体数量统计(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/storage` | dashboard | `gateway` | dashboard.storage | 本地存储空间 |
|
||||
| `GET` | `/api/v1/dashboard/storage2` | dashboard | `alternate-auth-duplicate` | moviepilot-api | 本地存储空间(API_TOKEN) |
|
||||
| `GET` | `/api/v1/dashboard/system` | dashboard | `gateway` | dashboard.system | 系统摘要信息 |
|
||||
| `GET` | `/api/v1/dashboard/transfer` | dashboard | `gateway` | dashboard.transfer.statistics | 文件整理统计 |
|
||||
| `GET` | `/api/v1/discover/bangumi` | discover | `consolidated` | moviepilot-api | 探索Bangumi |
|
||||
| `GET` | `/api/v1/discover/douban_movies` | discover | `consolidated` | moviepilot-api | 探索豆瓣电影 |
|
||||
| `GET` | `/api/v1/discover/douban_tvs` | discover | `consolidated` | moviepilot-api | 探索豆瓣剧集 |
|
||||
| `GET` | `/api/v1/discover/source` | discover | `consolidated` | moviepilot-api | 获取探索数据源 |
|
||||
| `GET` | `/api/v1/discover/tmdb_movies` | discover | `consolidated` | moviepilot-api | 探索TMDB电影 |
|
||||
| `GET` | `/api/v1/discover/tmdb_tvs` | discover | `consolidated` | moviepilot-api | 探索TMDB剧集 |
|
||||
| `GET` | `/api/v1/douban/credits/{doubanid}/{type_name}` | douban | `consolidated` | moviepilot-api | 豆瓣演员阵容 |
|
||||
| `GET` | `/api/v1/douban/person/credits/{person_id}` | douban | `consolidated` | moviepilot-api | 人物参演作品 |
|
||||
| `GET` | `/api/v1/douban/person/{person_id}` | douban | `consolidated` | moviepilot-api | 人物详情 |
|
||||
| `GET` | `/api/v1/douban/recommend/{doubanid}/{type_name}` | douban | `consolidated` | moviepilot-api | 豆瓣推荐电影/电视剧 |
|
||||
| `GET` | `/api/v1/douban/{doubanid}` | douban | `consolidated` | moviepilot-api | 查询豆瓣详情 |
|
||||
| `GET` | `/api/v1/download/` | download | `gateway` | download.tasks.active | 正在下载 |
|
||||
| `POST` | `/api/v1/download/` | download | `consolidated` | download.add | 添加下载(含媒体信息) |
|
||||
| `POST` | `/api/v1/download/add` | download | `gateway` | download.add | 添加下载(不含媒体信息) |
|
||||
| `GET` | `/api/v1/download/clients` | download | `gateway` | download.clients | 查询可用下载器 |
|
||||
| `GET` | `/api/v1/download/paths` | download | `gateway` | download.paths | 查询可用下载路径 |
|
||||
| `GET` | `/api/v1/download/start/{hashString}` | download | `provider-skill` | downloader-operation | 开始任务 |
|
||||
| `GET` | `/api/v1/download/stop/{hashString}` | download | `provider-skill` | downloader-operation | 暂停任务 |
|
||||
| `POST` | `/api/v1/download/subtitle` | download | `provider-skill` | downloader-operation | 下载字幕 |
|
||||
| `DELETE` | `/api/v1/download/{hashString}` | download | `provider-skill` | downloader-operation | 删除下载任务 |
|
||||
| `PATCH` | `/api/v1/download/{hashString}` | download | `provider-skill` | downloader-operation | 高级更新下载任务 |
|
||||
| `DELETE` | `/api/v1/history/download` | history | `gateway` | download.history.delete | 删除下载历史记录 |
|
||||
| `GET` | `/api/v1/history/download` | history | `gateway` | download.history.list | 查询下载历史记录 |
|
||||
| `GET` | `/api/v1/history/empty/transfer` | history | `gateway` | transfer.history.clear | 清空整理记录 |
|
||||
| `DELETE` | `/api/v1/history/transfer` | history | `gateway` | transfer.history.delete | 删除整理记录 |
|
||||
| `GET` | `/api/v1/history/transfer` | history | `gateway` | transfer.history | 查询整理记录 |
|
||||
| `POST` | `/api/v1/history/transfer/ai-redo` | history | `gateway` | transfer.history.redo_batch | 智能助手批量重新整理 |
|
||||
| `POST` | `/api/v1/history/transfer/{history_id}/ai-redo` | history | `gateway` | transfer.history.redo | 智能助手重新整理 |
|
||||
| `POST` | `/api/v1/llm/manage` | llm | `transport_or_identity` | host-runtime | LLM提供商统一管理 |
|
||||
| `GET` | `/api/v1/llm/provider-auth/callback/{provider_id}` | llm | `transport_or_identity` | host-runtime | LLM提供商OAuth回调 |
|
||||
| `POST` | `/api/v1/login/access-token` | login | `transport_or_identity` | host-runtime | 获取token |
|
||||
| `GET` | `/api/v1/login/initialization` | login | `transport_or_identity` | host-runtime | 查询首次初始化状态 |
|
||||
| `POST` | `/api/v1/login/initialization` | login | `transport_or_identity` | host-runtime | 完成首次初始化 |
|
||||
| `GET` | `/api/v1/login/wallpaper` | login | `transport_or_identity` | host-runtime | 登录页面电影海报 |
|
||||
| `GET` | `/api/v1/login/wallpapers` | login | `transport_or_identity` | host-runtime | 登录页面电影海报列表 |
|
||||
| `DELETE` | `/api/v1/mcp` | mcp | `transport_or_identity` | host-runtime | 终止 MCP 会话 |
|
||||
| `POST` | `/api/v1/mcp` | mcp | `transport_or_identity` | host-runtime | MCP JSON-RPC 端点 |
|
||||
| `GET` | `/api/v1/mcp/tools` | mcp | `transport_or_identity` | host-runtime | 列出所有可用工具 |
|
||||
| `POST` | `/api/v1/mcp/tools/call` | mcp | `transport_or_identity` | host-runtime | 调用工具 |
|
||||
| `GET` | `/api/v1/mcp/tools/{tool_name}` | mcp | `transport_or_identity` | host-runtime | 获取工具详情 |
|
||||
| `GET` | `/api/v1/mcp/tools/{tool_name}/schema` | mcp | `transport_or_identity` | host-runtime | 获取工具参数Schema |
|
||||
| `GET` | `/api/v1/media/category` | media | `gateway` | media.categories | 查询自动分类配置 |
|
||||
| `GET` | `/api/v1/media/category/config` | media | `gateway` | media.category.config.get | 获取分类策略配置 |
|
||||
| `POST` | `/api/v1/media/category/config` | media | `gateway` | media.category.config.update | 保存分类策略配置 |
|
||||
| `GET` | `/api/v1/media/group/seasons/{episode_group}` | media | `gateway` | media.episode_group.seasons | 查询剧集组季信息 |
|
||||
| `GET` | `/api/v1/media/groups/{tmdbid}` | media | `gateway` | media.episode_groups | 查询媒体剧集组 |
|
||||
| `GET` | `/api/v1/media/recognize` | media | `gateway` | media.recognize | 识别媒体信息(种子) |
|
||||
| `GET` | `/api/v1/media/recognize2` | media | `alternate-auth-duplicate` | moviepilot-api | 识别种子媒体信息(API_TOKEN) |
|
||||
| `GET` | `/api/v1/media/recognize_file` | media | `gateway` | media.recognize_file | 识别媒体信息(文件) |
|
||||
| `GET` | `/api/v1/media/recognize_file2` | media | `alternate-auth-duplicate` | moviepilot-api | 识别文件媒体信息(API_TOKEN) |
|
||||
| `POST` | `/api/v1/media/scrape/{storage}` | media | `gateway` | media.scrape | 刮削媒体信息 |
|
||||
| `GET` | `/api/v1/media/search` | media | `gateway` | media.person.search, media.search | 搜索媒体/人物信息 |
|
||||
| `GET` | `/api/v1/media/seasons` | media | `gateway` | media.seasons | 查询媒体季信息 |
|
||||
| `GET` | `/api/v1/media/source` | media | `gateway` | media.sources | 获取媒体数据源 |
|
||||
| `GET` | `/api/v1/media/{media_id}` | media | `gateway` | media.detail | 查询媒体详情 |
|
||||
| `GET` | `/api/v1/mediaserver/clients` | mediaserver | `provider-skill` | mediaserver-operation | 查询可用媒体服务器 |
|
||||
| `GET` | `/api/v1/mediaserver/exists` | mediaserver | `gateway` | library.exists | 查询本地是否存在(数据库) |
|
||||
| `POST` | `/api/v1/mediaserver/exists_remote` | mediaserver | `provider-skill` | mediaserver-operation | 查询已存在的剧集信息(媒体服务器) |
|
||||
| `GET` | `/api/v1/mediaserver/latest` | mediaserver | `gateway` | library.latest | 最新入库条目 |
|
||||
| `GET` | `/api/v1/mediaserver/library` | mediaserver | `provider-skill` | mediaserver-operation | 媒体库列表 |
|
||||
| `POST` | `/api/v1/mediaserver/notexists` | mediaserver | `provider-skill` | mediaserver-operation | 查询媒体库缺失信息(媒体服务器) |
|
||||
| `GET` | `/api/v1/mediaserver/play/{itemid}` | mediaserver | `provider-skill` | mediaserver-operation | 在线播放 |
|
||||
| `GET` | `/api/v1/mediaserver/playing` | mediaserver | `provider-skill` | mediaserver-operation | 正在播放条目 |
|
||||
| `GET` | `/api/v1/message/` | message | `transport_or_identity` | host-runtime | 回调请求验证 |
|
||||
| `POST` | `/api/v1/message/` | message | `transport_or_identity` | host-runtime | 接收用户消息 |
|
||||
| `POST` | `/api/v1/message/agent/callback` | agent | `transport_or_identity` | host-runtime | Web 智能助手按钮回调 |
|
||||
| `GET` | `/api/v1/message/agent/commands` | agent | `gateway` | slash.list | 获取 Web 智能助手可用命令 |
|
||||
| `POST` | `/api/v1/message/agent/commands/run` | agent | `gateway` | slash.run | 执行 Agent 斜杠命令 |
|
||||
| `GET` | `/api/v1/message/agent/file/{file_id}` | agent | `transport_or_identity` | host-runtime | 下载 Web 智能助手附件 |
|
||||
| `GET` | `/api/v1/message/agent/mcp/servers` | agent | `transport_or_identity` | host-runtime | 查询 Agent MCP 服务器配置 |
|
||||
| `POST` | `/api/v1/message/agent/mcp/servers` | agent | `transport_or_identity` | host-runtime | 保存 Agent MCP 服务器配置 |
|
||||
| `POST` | `/api/v1/message/agent/mcp/servers/test` | agent | `transport_or_identity` | host-runtime | 测试 Agent MCP 服务器 |
|
||||
| `GET` | `/api/v1/message/agent/sessions` | agent | `transport_or_identity` | host-runtime | 获取 Agent 历史会话 |
|
||||
| `DELETE` | `/api/v1/message/agent/sessions/{session_id}` | agent | `transport_or_identity` | host-runtime | 删除 Agent 历史会话 |
|
||||
| `GET` | `/api/v1/message/agent/sessions/{session_id}` | agent | `transport_or_identity` | host-runtime | 获取 Agent 历史会话详情 |
|
||||
| `PUT` | `/api/v1/message/agent/sessions/{session_id}/display` | agent | `transport_or_identity` | host-runtime | 保存 Agent 展示会话 |
|
||||
| `POST` | `/api/v1/message/agent/sessions/{session_id}/stop` | agent | `transport_or_identity` | host-runtime | 停止 Web 智能助手当前任务 |
|
||||
| `POST` | `/api/v1/message/agent/stream` | agent | `transport_or_identity` | host-runtime | Web智能助手流式对话 |
|
||||
| `POST` | `/api/v1/message/agent/upload` | agent | `transport_or_identity` | host-runtime | 上传 Web 智能助手附件 |
|
||||
| `DELETE` | `/api/v1/message/notification` | message | `transport_or_identity` | host-runtime | 清理通知消息 |
|
||||
| `GET` | `/api/v1/message/notification` | message | `transport_or_identity` | host-runtime | 获取通知消息 |
|
||||
| `GET` | `/api/v1/message/web` | message | `transport_or_identity` | host-runtime | 获取WEB消息 |
|
||||
| `POST` | `/api/v1/message/web` | message | `transport_or_identity` | host-runtime | 接收WEB消息 |
|
||||
| `POST` | `/api/v1/message/webpush/send` | message | `transport_or_identity` | host-runtime | 发送webpush通知 |
|
||||
| `POST` | `/api/v1/message/webpush/subscribe` | message | `transport_or_identity` | host-runtime | 客户端webpush通知订阅 |
|
||||
| `POST` | `/api/v1/mfa/otp/disable` | mfa | `transport_or_identity` | host-runtime | 关闭当前用户的 OTP 验证 |
|
||||
| `POST` | `/api/v1/mfa/otp/generate` | mfa | `transport_or_identity` | host-runtime | 生成 OTP 验证 URI |
|
||||
| `POST` | `/api/v1/mfa/otp/verify` | mfa | `transport_or_identity` | host-runtime | 绑定并验证 OTP |
|
||||
| `POST` | `/api/v1/mfa/passkey/authenticate/finish` | mfa | `transport_or_identity` | host-runtime | 完成 PassKey 认证 |
|
||||
| `POST` | `/api/v1/mfa/passkey/authenticate/start` | mfa | `transport_or_identity` | host-runtime | 开始 PassKey 认证 |
|
||||
| `POST` | `/api/v1/mfa/passkey/delete` | mfa | `transport_or_identity` | host-runtime | 删除 PassKey |
|
||||
| `GET` | `/api/v1/mfa/passkey/list` | mfa | `transport_or_identity` | host-runtime | 获取当前用户的 PassKey 列表 |
|
||||
| `POST` | `/api/v1/mfa/passkey/register/finish` | mfa | `transport_or_identity` | host-runtime | 完成注册 PassKey |
|
||||
| `POST` | `/api/v1/mfa/passkey/register/start` | mfa | `transport_or_identity` | host-runtime | 开始注册 PassKey |
|
||||
| `GET` | `/api/v1/music/album/{album_id}` | music | `gateway` | music.album.get | 查询音乐专辑详情 |
|
||||
| `GET` | `/api/v1/music/album/{album_id}/related` | music | `gateway` | music.album.related | 查询关联音乐专辑 |
|
||||
| `GET` | `/api/v1/music/artist/{artist_id}` | music | `gateway` | music.artist.get | 查询音乐艺术家详情 |
|
||||
| `GET` | `/api/v1/music/artist/{artist_id}/albums` | music | `gateway` | music.artist.albums | 查询艺术家的专辑列表 |
|
||||
| `GET` | `/api/v1/music/artist/{artist_id}/related` | music | `gateway` | music.artist.related | 查询关联艺术家 |
|
||||
| `DELETE` | `/api/v1/music/cache` | music | `gateway` | music.cache.clear | 清空音乐识别缓存 |
|
||||
| `GET` | `/api/v1/music/cache` | music | `gateway` | music.cache.get | 查询音乐识别缓存 |
|
||||
| `DELETE` | `/api/v1/music/cache/{cache_key}` | music | `gateway` | music.cache.delete | 删除指定音乐识别缓存 |
|
||||
| `GET` | `/api/v1/music/explore` | music | `gateway` | music.explore | 探索音乐 |
|
||||
| `POST` | `/api/v1/music/recognize` | music | `gateway` | music.recognize | 识别音乐元数据详情 |
|
||||
| `POST` | `/api/v1/notification/config` | notification | `transport_or_identity` | host-runtime | 保存通知渠道并同步登录缓存 |
|
||||
| `POST` | `/api/v1/notification/manage` | notification | `transport_or_identity` | host-runtime | 通知渠道统一管理 |
|
||||
| `POST` | `/api/v1/openai/v1/chat/completions` | openai | `transport_or_identity` | host-runtime | OpenAI compatible chat completions |
|
||||
| `GET` | `/api/v1/openai/v1/models` | openai | `transport_or_identity` | host-runtime | OpenAI compatible models |
|
||||
| `POST` | `/api/v1/openai/v1/responses` | openai | `transport_or_identity` | host-runtime | OpenAI compatible responses |
|
||||
| `GET` | `/api/v1/plugin/` | plugin | `gateway` | plugin.installed, plugin.market | 所有插件 |
|
||||
| `POST` | `/api/v1/plugin/clone/{plugin_id}` | plugin | `gateway` | plugin.clone | 创建插件分身 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/meta` | plugin | `ui_presentation` | host-ui | 获取所有插件仪表板元信息 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 |
|
||||
| `GET` | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 |
|
||||
| `GET` | `/api/v1/plugin/file/{plugin_id}/{filepath}` | plugin | `stream_or_binary` | host-transport | 获取插件静态文件 |
|
||||
| `GET` | `/api/v1/plugin/folders` | plugin | `gateway` | plugin.folders.get | 获取插件文件夹配置 |
|
||||
| `POST` | `/api/v1/plugin/folders` | plugin | `gateway` | plugin.folders.update | 保存插件文件夹配置 |
|
||||
| `DELETE` | `/api/v1/plugin/folders/{folder_name}` | plugin | `gateway` | plugin.folder.delete | 删除插件文件夹 |
|
||||
| `POST` | `/api/v1/plugin/folders/{folder_name}` | plugin | `gateway` | plugin.folder.create | 创建插件文件夹 |
|
||||
| `PUT` | `/api/v1/plugin/folders/{folder_name}/plugins` | plugin | `gateway` | plugin.folder.plugins.update | 更新文件夹中的插件 |
|
||||
| `GET` | `/api/v1/plugin/form/{plugin_id}` | plugin | `gateway` | plugin.config.get | 获取插件表单页面 |
|
||||
| `GET` | `/api/v1/plugin/history/{plugin_id}` | plugin | `gateway` | plugin.history | 获取插件更新说明 |
|
||||
| `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 |
|
||||
| `GET` | `/api/v1/plugin/installed` | plugin | `consolidated` | plugin.installed | 已安装插件 |
|
||||
| `GET` | `/api/v1/plugin/page/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件数据页面 |
|
||||
| `GET` | `/api/v1/plugin/rating` | plugin | `gateway` | plugin.ratings | 批量查询插件评分 |
|
||||
| `GET` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating | 查询插件评分 |
|
||||
| `POST` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating.submit | 提交插件评分 |
|
||||
| `GET` | `/api/v1/plugin/releases/{plugin_id}` | plugin | `gateway` | plugin.releases | 获取插件Release版本 |
|
||||
| `GET` | `/api/v1/plugin/reload/{plugin_id}` | plugin | `gateway` | plugin.reload | 重新加载插件 |
|
||||
| `GET` | `/api/v1/plugin/remotes` | plugin | `transport_or_identity` | host-runtime | 获取插件联邦组件列表 |
|
||||
| `GET` | `/api/v1/plugin/reset/{plugin_id}` | plugin | `gateway` | plugin.reset | 重置插件配置及数据 |
|
||||
| `GET` | `/api/v1/plugin/runtime` | plugin | `gateway` | plugin.runtime.status | 插件运行时收敛状态 |
|
||||
| `GET` | `/api/v1/plugin/runtime/capabilities` | plugin | `gateway` | plugin.capabilities | 查询插件运行能力 |
|
||||
| `GET` | `/api/v1/plugin/runtime/{plugin_id}/data` | plugin | `gateway` | plugin.data | 查询插件持久化数据 |
|
||||
| `GET` | `/api/v1/plugin/sidebar_nav` | plugin | `ui_presentation` | host-ui | 获取插件侧栏导航项 |
|
||||
| `GET` | `/api/v1/plugin/source/{plugin_id}` | plugin | `gateway` | plugin.source.options | 获取插件来源身份 |
|
||||
| `POST` | `/api/v1/plugin/source/{plugin_id}` | plugin | `gateway` | plugin.source.change | 切换插件来源 |
|
||||
| `POST` | `/api/v1/plugin/source/{plugin_id}/install` | plugin | `gateway` | plugin.source.install | 按明确来源安装插件 |
|
||||
| `GET` | `/api/v1/plugin/source/{plugin_id}/options` | plugin | `consolidated` | plugin.source.options | 获取插件来源候选 |
|
||||
| `GET` | `/api/v1/plugin/statistic` | plugin | `gateway` | plugin.statistics | 插件安装统计 |
|
||||
| `DELETE` | `/api/v1/plugin/{plugin_id}` | plugin | `gateway` | plugin.uninstall | 卸载插件 |
|
||||
| `GET` | `/api/v1/plugin/{plugin_id}` | plugin | `consolidated` | plugin.config.get | 获取插件配置 |
|
||||
| `PUT` | `/api/v1/plugin/{plugin_id}` | plugin | `gateway` | plugin.config.update | 更新插件配置 |
|
||||
| `GET` | `/api/v1/recommend/agent` | recommend | `gateway` | recommendation.list | 统一获取 Agent 推荐结果 |
|
||||
| `GET` | `/api/v1/recommend/bangumi_calendar` | recommend | `consolidated` | moviepilot-api | Bangumi每日放送 |
|
||||
| `GET` | `/api/v1/recommend/douban_movie_hot` | recommend | `consolidated` | moviepilot-api | 豆瓣热门电影 |
|
||||
| `GET` | `/api/v1/recommend/douban_movie_top250` | recommend | `consolidated` | moviepilot-api | 豆瓣电影TOP250 |
|
||||
| `GET` | `/api/v1/recommend/douban_movies` | recommend | `consolidated` | moviepilot-api | 豆瓣电影 |
|
||||
| `GET` | `/api/v1/recommend/douban_showing` | recommend | `consolidated` | moviepilot-api | 豆瓣正在热映 |
|
||||
| `GET` | `/api/v1/recommend/douban_tv_animation` | recommend | `consolidated` | moviepilot-api | 豆瓣动画剧集 |
|
||||
| `GET` | `/api/v1/recommend/douban_tv_hot` | recommend | `consolidated` | moviepilot-api | 豆瓣热门电视剧 |
|
||||
| `GET` | `/api/v1/recommend/douban_tv_weekly_chinese` | recommend | `consolidated` | moviepilot-api | 豆瓣国产剧集周榜 |
|
||||
| `GET` | `/api/v1/recommend/douban_tv_weekly_global` | recommend | `consolidated` | moviepilot-api | 豆瓣全球剧集周榜 |
|
||||
| `GET` | `/api/v1/recommend/douban_tvs` | recommend | `consolidated` | moviepilot-api | 豆瓣剧集 |
|
||||
| `GET` | `/api/v1/recommend/music_douban` | recommend | `consolidated` | moviepilot-api | 豆瓣音乐推荐 |
|
||||
| `GET` | `/api/v1/recommend/music_weekly` | recommend | `consolidated` | moviepilot-api | ListenBrainz 本周热门音乐 |
|
||||
| `GET` | `/api/v1/recommend/source` | recommend | `consolidated` | moviepilot-api | 获取推荐数据源 |
|
||||
| `GET` | `/api/v1/recommend/tmdb_movies` | recommend | `consolidated` | moviepilot-api | TMDB电影 |
|
||||
| `GET` | `/api/v1/recommend/tmdb_trending` | recommend | `consolidated` | moviepilot-api | TMDB流行趋势 |
|
||||
| `GET` | `/api/v1/recommend/tmdb_tvs` | recommend | `consolidated` | moviepilot-api | TMDB剧集 |
|
||||
| `GET` | `/api/v1/rule/builtin` | rule | `gateway` | filter.builtin | 查询内置过滤规则 |
|
||||
| `GET` | `/api/v1/rule/custom` | rule | `gateway` | filter.custom | 查询自定义过滤规则 |
|
||||
| `POST` | `/api/v1/rule/custom` | rule | `gateway` | filter.custom.add | 新增自定义过滤规则 |
|
||||
| `DELETE` | `/api/v1/rule/custom/{rule_id}` | rule | `gateway` | filter.custom.delete | 删除自定义过滤规则 |
|
||||
| `PUT` | `/api/v1/rule/custom/{rule_id}` | rule | `gateway` | filter.custom.update | 更新自定义过滤规则 |
|
||||
| `GET` | `/api/v1/rule/groups` | rule | `gateway` | filter.groups | 查询过滤规则组 |
|
||||
| `POST` | `/api/v1/rule/groups` | rule | `gateway` | filter.group.add | 新增过滤规则组 |
|
||||
| `DELETE` | `/api/v1/rule/groups/{name}` | rule | `gateway` | filter.group.delete | 删除过滤规则组 |
|
||||
| `PUT` | `/api/v1/rule/groups/{name}` | rule | `gateway` | filter.group.update | 更新过滤规则组 |
|
||||
| `GET` | `/api/v1/search/last` | search | `consolidated` | search.results | 查询搜索结果 |
|
||||
| `GET` | `/api/v1/search/last/context` | search | `gateway` | search.results | 查询上次搜索上下文 |
|
||||
| `GET` | `/api/v1/search/media/{media_id}` | search | `gateway` | search.torrents | 精确搜索资源 |
|
||||
| `GET` | `/api/v1/search/media/{media_id}/stream` | search | `consolidated` | search.torrents | 渐进式精确搜索资源 |
|
||||
| `POST` | `/api/v1/search/recommend` | search | `gateway` | search.recommend | AI推荐资源 |
|
||||
| `GET` | `/api/v1/search/subtitle/media/{media_id}` | search | `gateway` | subtitle.search.media | 精确搜索字幕 |
|
||||
| `GET` | `/api/v1/search/subtitle/media/{media_id}/stream` | search | `consolidated` | subtitle.search.media | 渐进式精确搜索字幕 |
|
||||
| `GET` | `/api/v1/search/subtitle/title` | search | `gateway` | subtitle.search.title | 模糊搜索字幕 |
|
||||
| `GET` | `/api/v1/search/subtitle/title/stream` | search | `consolidated` | subtitle.search.title | 渐进式模糊搜索字幕 |
|
||||
| `GET` | `/api/v1/search/title` | search | `gateway` | search.title | 模糊搜索资源 |
|
||||
| `GET` | `/api/v1/search/title/stream` | search | `consolidated` | search.title | 渐进式模糊搜索资源 |
|
||||
| `GET` | `/api/v1/site/` | site | `consolidated` | site.list | 所有站点 |
|
||||
| `POST` | `/api/v1/site/` | site | `gateway` | site.add | 新增站点 |
|
||||
| `PUT` | `/api/v1/site/` | site | `gateway` | site.update | 更新站点 |
|
||||
| `GET` | `/api/v1/site/agent` | site | `gateway` | site.list | 查询 Agent 可用站点 |
|
||||
| `GET` | `/api/v1/site/auth` | site | `gateway` | site.auth.options | 查询认证站点 |
|
||||
| `POST` | `/api/v1/site/auth` | site | `gateway` | site.authenticate | 用户站点认证 |
|
||||
| `GET` | `/api/v1/site/category/{site_id}` | site | `gateway` | site.category | 站点分类 |
|
||||
| `GET` | `/api/v1/site/cookie/{site_id}` | site | `consolidated` | site.cookie.update | 更新站点Cookie&UA |
|
||||
| `POST` | `/api/v1/site/cookie/{site_id}` | site | `gateway` | site.cookie.update | 更新站点Cookie&UA |
|
||||
| `GET` | `/api/v1/site/cookiecloud` | site | `gateway` | site.cookiecloud.sync | CookieCloud同步 |
|
||||
| `GET` | `/api/v1/site/domain/{site_url}` | site | `consolidated` | site.list | 站点详情 |
|
||||
| `GET` | `/api/v1/site/icon/{site_id}` | site | `stream_or_binary` | host-transport | 站点图标 |
|
||||
| `GET` | `/api/v1/site/mapping` | site | `gateway` | site.mapping | 获取站点域名到名称的映射 |
|
||||
| `GET` | `/api/v1/site/media/{media_type}` | site | `gateway` | site.searchable | 按媒体类型获取可搜索站点 |
|
||||
| `POST` | `/api/v1/site/priorities` | site | `gateway` | site.priorities.update | 批量更新站点优先级 |
|
||||
| `GET` | `/api/v1/site/reset` | site | `gateway` | site.reset | 重置站点 |
|
||||
| `GET` | `/api/v1/site/resource/{site_id}` | site | `gateway` | site.resource | 站点资源 |
|
||||
| `GET` | `/api/v1/site/rss` | site | `gateway` | site.rss | 所有订阅站点 |
|
||||
| `GET` | `/api/v1/site/statistic` | site | `gateway` | site.statistics | 所有站点统计信息 |
|
||||
| `GET` | `/api/v1/site/statistic/{site_url}` | site | `gateway` | site.statistic | 特定站点统计信息 |
|
||||
| `GET` | `/api/v1/site/supporting` | site | `gateway` | site.supporting | 获取支持的站点列表 |
|
||||
| `GET` | `/api/v1/site/test/{site_id}` | site | `gateway` | site.test | 连接测试 |
|
||||
| `GET` | `/api/v1/site/userdata/latest` | site | `gateway` | site.userdata.latest | 查询所有站点最新用户数据 |
|
||||
| `GET` | `/api/v1/site/userdata/{site_id}` | site | `gateway` | site.userdata | 查询某站点用户数据 |
|
||||
| `POST` | `/api/v1/site/userdata/{site_id}` | site | `gateway` | site.userdata.refresh | 更新站点用户数据 |
|
||||
| `DELETE` | `/api/v1/site/{site_id}` | site | `gateway` | site.delete | 删除站点 |
|
||||
| `GET` | `/api/v1/site/{site_id}` | site | `consolidated` | site.list | 站点详情 |
|
||||
| `POST` | `/api/v1/storage/agent/list` | storage | `gateway` | storage.list | 查询 Agent 可用目录和文件 |
|
||||
| `POST` | `/api/v1/storage/delete` | storage | `gateway` | storage.delete | 删除文件或目录 |
|
||||
| `GET` | `/api/v1/storage/directories` | storage | `gateway` | storage.settings | 查询目录配置 |
|
||||
| `POST` | `/api/v1/storage/download` | storage | `stream_or_binary` | host-transport | 下载文件 |
|
||||
| `POST` | `/api/v1/storage/image` | storage | `stream_or_binary` | host-transport | 预览图片 |
|
||||
| `POST` | `/api/v1/storage/list` | storage | `consolidated` | storage.list | 所有目录和文件 |
|
||||
| `POST` | `/api/v1/storage/manage` | storage | `gateway` | storage.manage | 网盘存储统一管理 |
|
||||
| `POST` | `/api/v1/storage/mkdir` | storage | `gateway` | storage.mkdir | 创建目录 |
|
||||
| `POST` | `/api/v1/storage/rename` | storage | `gateway` | storage.rename | 重命名文件或目录 |
|
||||
| `GET` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.list | 查询所有订阅 |
|
||||
| `POST` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.add | 新增订阅 |
|
||||
| `PUT` | `/api/v1/subscribe/` | subscribe | `gateway` | subscription.update | 更新订阅 |
|
||||
| `GET` | `/api/v1/subscribe/check` | subscribe | `gateway` | subscription.metadata.refresh | 刷新订阅 TMDB 信息 |
|
||||
| `GET` | `/api/v1/subscribe/files/{subscribe_id}` | subscribe | `gateway` | subscription.files | 订阅相关文件信息 |
|
||||
| `DELETE` | `/api/v1/subscribe/follow` | subscribe | `gateway` | subscription.follow.delete | 取消Follow订阅分享人 |
|
||||
| `GET` | `/api/v1/subscribe/follow` | subscribe | `gateway` | subscription.follow.list | 查询已Follow的订阅分享人 |
|
||||
| `POST` | `/api/v1/subscribe/follow` | subscribe | `gateway` | subscription.follow.add | Follow订阅分享人 |
|
||||
| `POST` | `/api/v1/subscribe/fork` | subscribe | `gateway` | subscription.fork | 复用订阅 |
|
||||
| `DELETE` | `/api/v1/subscribe/history/{history_id}` | subscribe | `gateway` | subscription.history.delete | 删除订阅历史 |
|
||||
| `GET` | `/api/v1/subscribe/history/{mtype}` | subscribe | `gateway` | subscription.history | 查询订阅历史 |
|
||||
| `GET` | `/api/v1/subscribe/list` | subscribe | `consolidated` | subscription.list | 查询所有订阅(API_TOKEN) |
|
||||
| `DELETE` | `/api/v1/subscribe/media/{media_id}` | subscribe | `gateway` | subscription.delete_by_media | 删除订阅 |
|
||||
| `GET` | `/api/v1/subscribe/media/{media_id}` | subscribe | `gateway` | subscription.find | 查询订阅 |
|
||||
| `GET` | `/api/v1/subscribe/popular` | subscribe | `gateway` | subscription.popular | 热门订阅(基于用户共享数据) |
|
||||
| `GET` | `/api/v1/subscribe/refresh` | subscribe | `gateway` | subscription.refresh | 刷新订阅 |
|
||||
| `GET` | `/api/v1/subscribe/reset/{subid}` | subscribe | `gateway` | subscription.reset | 重置订阅 |
|
||||
| `GET` | `/api/v1/subscribe/search` | subscribe | `gateway` | subscription.search_all | 搜索所有订阅 |
|
||||
| `GET` | `/api/v1/subscribe/search/{subscribe_id}` | subscribe | `gateway` | subscription.search | 搜索订阅 |
|
||||
| `POST` | `/api/v1/subscribe/seerr` | subscribe | `transport_or_identity` | host-runtime | OverSeerr/JellySeerr通知订阅 |
|
||||
| `POST` | `/api/v1/subscribe/share` | subscribe | `gateway` | subscription.share | 分享订阅 |
|
||||
| `GET` | `/api/v1/subscribe/share/statistics` | subscribe | `gateway` | subscription.share.statistics | 查询订阅分享统计 |
|
||||
| `DELETE` | `/api/v1/subscribe/share/{share_id}` | subscribe | `gateway` | subscription.share.delete | 删除分享 |
|
||||
| `GET` | `/api/v1/subscribe/shares` | subscribe | `gateway` | subscription.shares | 查询分享的订阅 |
|
||||
| `PUT` | `/api/v1/subscribe/status/{subid}` | subscribe | `gateway` | subscription.status.update | 更新订阅状态 |
|
||||
| `GET` | `/api/v1/subscribe/user/{username}` | subscribe | `gateway` | subscription.user.list | 用户订阅 |
|
||||
| `DELETE` | `/api/v1/subscribe/{subscribe_id}` | subscribe | `gateway` | subscription.delete | 删除订阅 |
|
||||
| `GET` | `/api/v1/subscribe/{subscribe_id}` | subscribe | `gateway` | subscription.get | 订阅详情 |
|
||||
| `GET` | `/api/v1/system/cache/image` | system | `stream_or_binary` | host-transport | 图片缓存 |
|
||||
| `GET` | `/api/v1/system/database/backups` | system | `gateway` | database.backups.list | 查询受管数据库备份 |
|
||||
| `POST` | `/api/v1/system/database/backups` | system | `gateway` | database.backups.create | 立即创建数据库备份 |
|
||||
| `DELETE` | `/api/v1/system/database/backups/{name}` | system | `gateway` | database.backups.delete | 删除受管数据库备份 |
|
||||
| `POST` | `/api/v1/system/database/backups/{name}/verify` | system | `gateway` | database.backups.verify | 校验受管数据库备份 |
|
||||
| `GET` | `/api/v1/system/env` | system | `consolidated` | config.system.get | 查询系统配置 |
|
||||
| `POST` | `/api/v1/system/env` | system | `consolidated` | config.system.update | 更新系统配置 |
|
||||
| `GET` | `/api/v1/system/global` | system | `consolidated` | config.system.get | 查询非敏感系统设置 |
|
||||
| `GET` | `/api/v1/system/global/user` | system | `gateway` | config.user.get | 查询用户相关系统设置 |
|
||||
| `GET` | `/api/v1/system/identifiers` | system | `gateway` | config.identifiers.get | 查询自定义识别词 |
|
||||
| `POST` | `/api/v1/system/identifiers` | system | `gateway` | config.identifiers.update | 更新自定义识别词 |
|
||||
| `GET` | `/api/v1/system/img/{proxy}` | system | `stream_or_binary` | host-transport | 图片代理 |
|
||||
| `GET` | `/api/v1/system/logging` | system | `stream_or_binary` | host-transport | 实时日志 |
|
||||
| `GET` | `/api/v1/system/logging/download/{name}` | system | `stream_or_binary` | host-transport | 下载日志 |
|
||||
| `GET` | `/api/v1/system/message` | system | `stream_or_binary` | host-transport | 实时消息 |
|
||||
| `GET` | `/api/v1/system/modulelist` | system | `gateway` | system.module.list | 查询已加载的模块ID列表 |
|
||||
| `GET` | `/api/v1/system/moduletest/{moduleid}` | system | `gateway` | system.module.test | 模块可用性测试 |
|
||||
| `GET` | `/api/v1/system/nettest` | system | `gateway` | system.network.test | 测试网络连通性 |
|
||||
| `GET` | `/api/v1/system/nettest/targets` | system | `gateway` | system.network.targets | 获取网络测试目标 |
|
||||
| `GET` | `/api/v1/system/ping` | system | `transport_or_identity` | host-runtime | 服务存活检测 |
|
||||
| `GET` | `/api/v1/system/progress/{process_type}` | system | `stream_or_binary` | host-transport | 实时进度 |
|
||||
| `GET` | `/api/v1/system/restart` | system | `gateway` | system.restart | 重启系统 |
|
||||
| `GET` | `/api/v1/system/ruletest` | system | `gateway` | filter.test | 过滤规则测试 |
|
||||
| `GET` | `/api/v1/system/runscheduler` | system | `gateway` | scheduler.run | 运行服务 |
|
||||
| `GET` | `/api/v1/system/runscheduler2` | system | `alternate-auth-duplicate` | moviepilot-api | 运行服务(API_TOKEN) |
|
||||
| `POST` | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | system | `gateway` | plugin.market.sync_wiki | 从Wiki同步插件市场仓库 |
|
||||
| `GET` | `/api/v1/system/setting/public/{key}` | system | `gateway` | config.public.get | 查询公开系统设置 |
|
||||
| `GET` | `/api/v1/system/setting/{key}` | system | `consolidated` | config.system.get | 查询系统设置 |
|
||||
| `POST` | `/api/v1/system/setting/{key}` | system | `consolidated` | config.system.update | 更新系统设置 |
|
||||
| `GET` | `/api/v1/system/settings` | system | `gateway` | config.system.get | Discover or read registered system settings |
|
||||
| `POST` | `/api/v1/system/settings` | system | `gateway` | config.system.update | Update one registered system setting |
|
||||
| `POST` | `/api/v1/system/update/check` | system | `gateway` | system.update.check | 立即检查系统更新 |
|
||||
| `POST` | `/api/v1/system/update/download` | system | `gateway` | system.update.download | 后台下载系统更新 |
|
||||
| `POST` | `/api/v1/system/update/install` | system | `gateway` | system.update.install | 确认重启安装系统更新 |
|
||||
| `GET` | `/api/v1/system/update/status` | system | `gateway` | system.update.status | 查询系统更新状态 |
|
||||
| `POST` | `/api/v1/system/upgrade` | system | `gateway` | system.upgrade.dev | Dev 更新并重启系统 |
|
||||
| `GET` | `/api/v1/system/usage/statistic` | system | `gateway` | system.usage.statistics | 查询安装版本统计报表 |
|
||||
| `GET` | `/api/v1/system/versions` | system | `gateway` | system.versions | 查询Github所有Release版本 |
|
||||
| `DELETE` | `/api/v1/tmdb/cache` | tmdb | `consolidated` | moviepilot-api | 清空 TheMovieDb 识别缓存 |
|
||||
| `GET` | `/api/v1/tmdb/cache` | tmdb | `consolidated` | moviepilot-api | 查询 TheMovieDb 识别缓存 |
|
||||
| `DELETE` | `/api/v1/tmdb/cache/{cache_key}` | tmdb | `consolidated` | moviepilot-api | 删除指定 TheMovieDb 识别缓存 |
|
||||
| `GET` | `/api/v1/tmdb/collection/{collection_id}` | tmdb | `consolidated` | moviepilot-api | 系列合集详情 |
|
||||
| `GET` | `/api/v1/tmdb/credits/{tmdbid}/{type_name}` | tmdb | `consolidated` | moviepilot-api | 演员阵容 |
|
||||
| `GET` | `/api/v1/tmdb/person/credits/{person_id}` | tmdb | `consolidated` | moviepilot-api | 人物参演作品 |
|
||||
| `GET` | `/api/v1/tmdb/person/{person_id}` | tmdb | `consolidated` | moviepilot-api | 人物详情 |
|
||||
| `GET` | `/api/v1/tmdb/recommend/{tmdbid}/{type_name}` | tmdb | `consolidated` | moviepilot-api | 推荐电影/电视剧 |
|
||||
| `GET` | `/api/v1/tmdb/seasons/{tmdbid}` | tmdb | `consolidated` | moviepilot-api | TMDB所有季 |
|
||||
| `GET` | `/api/v1/tmdb/similar/{tmdbid}/{type_name}` | tmdb | `consolidated` | moviepilot-api | 类似电影/电视剧 |
|
||||
| `GET` | `/api/v1/tmdb/{tmdbid}/{season}` | tmdb | `gateway` | media.episode_schedule | TMDB季所有集 |
|
||||
| `DELETE` | `/api/v1/torrent/cache` | torrent | `gateway` | torrent.cache.clear | 清理种子缓存 |
|
||||
| `GET` | `/api/v1/torrent/cache` | torrent | `gateway` | torrent.cache.get | 获取种子缓存 |
|
||||
| `POST` | `/api/v1/torrent/cache/refresh` | torrent | `gateway` | torrent.cache.refresh | 刷新种子缓存 |
|
||||
| `POST` | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | torrent | `gateway` | torrent.cache.reidentify | 重新识别种子 |
|
||||
| `DELETE` | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | torrent | `gateway` | torrent.cache.delete | 删除指定种子缓存 |
|
||||
| `POST` | `/api/v1/transfer/episode-format/recommend` | transfer | `gateway` | transfer.episode_format.recommend | 推荐集数定位模板 |
|
||||
| `POST` | `/api/v1/transfer/manual` | transfer | `gateway` | transfer.file | 手动转移 |
|
||||
| `POST` | `/api/v1/transfer/manual/history` | transfer | `gateway` | transfer.manual_history | 查询手动转移成功历史 |
|
||||
| `POST` | `/api/v1/transfer/manual/target-path` | transfer | `gateway` | transfer.target_path | 匹配手动转移目的路径 |
|
||||
| `GET` | `/api/v1/transfer/name` | transfer | `gateway` | transfer.name | 查询整理后的名称 |
|
||||
| `GET` | `/api/v1/transfer/now` | transfer | `consolidated` | scheduler.run | 立即执行下载器文件整理 |
|
||||
| `DELETE` | `/api/v1/transfer/queue` | transfer | `gateway` | transfer.queue.delete | 从整理队列中删除任务 |
|
||||
| `GET` | `/api/v1/transfer/queue` | transfer | `gateway` | transfer.queue | 查询整理队列 |
|
||||
| `GET` | `/api/v1/transfer/tasks/manual-reviews` | transfer | `gateway` | transfer.manual_reviews | 分页查询 durable 整理人工复核任务 |
|
||||
| `GET` | `/api/v1/transfer/tasks/{task_id}/manual-review` | transfer | `gateway` | transfer.manual_review | 查询 durable 整理人工复核详情 |
|
||||
| `POST` | `/api/v1/transfer/tasks/{task_id}/manual-review` | transfer | `gateway` | transfer.manual_review.resolve | 人工判定整理步骤的外部执行结果 |
|
||||
| `GET` | `/api/v1/user/` | user | `transport_or_identity` | host-runtime | 所有用户 |
|
||||
| `POST` | `/api/v1/user/` | user | `transport_or_identity` | host-runtime | 新增用户 |
|
||||
| `PUT` | `/api/v1/user/` | user | `transport_or_identity` | host-runtime | 更新用户 |
|
||||
| `POST` | `/api/v1/user/avatar/{user_id}` | user | `transport_or_identity` | host-runtime | 上传用户头像 |
|
||||
| `GET` | `/api/v1/user/config/{key}` | user | `transport_or_identity` | host-runtime | 查询用户配置 |
|
||||
| `POST` | `/api/v1/user/config/{key}` | user | `transport_or_identity` | host-runtime | 更新用户配置 |
|
||||
| `GET` | `/api/v1/user/current` | user | `transport_or_identity` | host-runtime | 当前登录用户信息 |
|
||||
| `PUT` | `/api/v1/user/current` | user | `transport_or_identity` | host-runtime | 更新当前用户资料 |
|
||||
| `DELETE` | `/api/v1/user/id/{user_id}` | user | `transport_or_identity` | host-runtime | 删除用户 |
|
||||
| `DELETE` | `/api/v1/user/name/{user_name}` | user | `transport_or_identity` | host-runtime | 删除用户 |
|
||||
| `GET` | `/api/v1/user/{username}` | user | `transport_or_identity` | host-runtime | 用户详情 |
|
||||
| `GET` | `/api/v1/webhook/` | webhook | `transport_or_identity` | host-runtime | Webhook消息响应 |
|
||||
| `POST` | `/api/v1/webhook/` | webhook | `transport_or_identity` | host-runtime | Webhook消息响应 |
|
||||
| `GET` | `/api/v1/workflow/` | workflow | `consolidated` | workflow.list | 所有工作流 |
|
||||
| `POST` | `/api/v1/workflow/` | workflow | `gateway` | workflow.create | 创建工作流 |
|
||||
| `GET` | `/api/v1/workflow/actions` | workflow | `gateway` | workflow.actions | 所有动作 |
|
||||
| `GET` | `/api/v1/workflow/agent` | workflow | `gateway` | workflow.list | 查询 Agent 可用工作流 |
|
||||
| `GET` | `/api/v1/workflow/event_types` | workflow | `gateway` | workflow.event_types | 获取所有事件类型 |
|
||||
| `POST` | `/api/v1/workflow/fork` | workflow | `gateway` | workflow.fork | 复用工作流 |
|
||||
| `GET` | `/api/v1/workflow/plugin/actions` | workflow | `gateway` | workflow.plugin.actions | 查询插件动作 |
|
||||
| `POST` | `/api/v1/workflow/share` | workflow | `gateway` | workflow.share | 分享工作流 |
|
||||
| `DELETE` | `/api/v1/workflow/share/{share_id}` | workflow | `gateway` | workflow.share.delete | 删除分享 |
|
||||
| `GET` | `/api/v1/workflow/shares` | workflow | `gateway` | workflow.shares | 查询分享的工作流 |
|
||||
| `DELETE` | `/api/v1/workflow/{workflow_id}` | workflow | `gateway` | workflow.delete | 删除工作流 |
|
||||
| `GET` | `/api/v1/workflow/{workflow_id}` | workflow | `gateway` | workflow.get | 工作流详情 |
|
||||
| `PUT` | `/api/v1/workflow/{workflow_id}` | workflow | `gateway` | workflow.update | 更新工作流 |
|
||||
| `POST` | `/api/v1/workflow/{workflow_id}/pause` | workflow | `gateway` | workflow.pause | 停用工作流 |
|
||||
| `POST` | `/api/v1/workflow/{workflow_id}/reset` | workflow | `gateway` | workflow.reset | 重置工作流 |
|
||||
| `POST` | `/api/v1/workflow/{workflow_id}/run` | workflow | `gateway` | workflow.run | 执行工作流 |
|
||||
| `POST` | `/api/v1/workflow/{workflow_id}/start` | workflow | `gateway` | workflow.start | 启用工作流 |
|
||||
|
||||
## Exposure Rule
|
||||
|
||||
Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.
|
||||
@@ -1,10 +1,10 @@
|
||||
# MoviePilot Agent 工具体系重构计划
|
||||
|
||||
> 状态:COMPLETE
|
||||
> 状态:IN PROGRESS — 本地验收已通过,提交推送与远端 CI 收口
|
||||
>
|
||||
> 建立日期:2026-08-31
|
||||
>
|
||||
> 当前基线:v3@6ee47795629f
|
||||
> 当前基线:v3@871632af257a663abc159c516acc027a81ab2314
|
||||
>
|
||||
> 关联目标:本线程已建立的 Agent 工具体系重构 Goal
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
| 工厂固定工具元组 | 82 个 | 12 个原生工具;默认再追加 send_local_file 与 moviepilot_api,共 14 个 |
|
||||
| 工具目录默认行为 | LLM_MAX_TOOLS=0,默认将完整目录绑定到主模型 | 默认完整目录已收敛为 14 个;按钮选择和语音发送仅在渠道能力满足时条件注入 |
|
||||
| 默认固定目录体积 | 工具名称、描述和 JSON Schema 合计约 113 KB | 实测 23,865 bytes,约下降 79% |
|
||||
| API Skill | skills/moviepilot-api/SKILL.md 通过任意 method/path 调用 | moviepilot_api 只接受 59 个固定 operation ID;Skill 以 allowed-api-operations 强制收敛 |
|
||||
| API Skill | skills/moviepilot-api/SKILL.md 通过任意 method/path 调用 | moviepilot_api 只接受 203 个固定 operation ID;Skill 与 MCP oneOf 逐 operation 暴露精确英文参数合同 |
|
||||
| API 身份 | API Token 映射为管理员级集成身份,不等于 Agent 当前用户/渠道身份 | Web/渠道身份绑定到真实用户;HTTP/MCP 管理入口绑定持久化超级用户,不接受模型注入 Token |
|
||||
| 下载器/媒体服务器 | 依赖内置低层 Agent 工具或有限 REST operation | downloader-operation 与 mediaserver-operation Skill 通过固定脚本调用已配置 provider API |
|
||||
| MCP/HTTP 工具管理 | 存在旧业务工具与同名 first-wins 选择空间 | 与主 Agent 共用严格唯一新目录;重名直接以 TOOL_IDENTITY_AMBIGUOUS 失败 |
|
||||
| 退役代码 | 旧实现仍位于 app/agent/tools/impl | 77 个退役文件已直接删除,其中 72 个工具模块、5 个辅助模块 |
|
||||
| 架构图 | 982 个宿主模块、8,430 条内部依赖边 | 917 个宿主模块、7,671 条内部依赖边,Application/Chain 具体 Adapter 直连仍为 0 |
|
||||
| 工作区状态 | 基线提交 6ee47795629f,与 origin/v3 对齐,初始工作区干净 | 重构与验证已完成,尚未提交或推送 |
|
||||
| 架构图 | 982 个宿主模块、8,430 条内部依赖边 | 919 个宿主模块、7,680 条内部依赖边,Application/Chain 具体 Adapter 直连仍为 0 |
|
||||
| 工作区状态 | 基线提交 871632af,与 origin/v3 对齐,初始工作区干净 | 生产改动、生成合同、静态检查、全量测试和固定 80% 覆盖率门禁已完成;提交、推送和远端 CI 尚待完成 |
|
||||
|
||||
## 3. 目标工具分层
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
- 工厂固定工具元组由 82 个降为 12 个原生工具。
|
||||
- 默认运行目录再追加 send_local_file 与单一 moviepilot_api 网关,共 14 个固定工具。
|
||||
- 其中 13 个是原生能力,1 个是 59-operation 结构化 API 网关。
|
||||
- 其中 13 个是原生能力,1 个是 203-operation 结构化 API 网关。
|
||||
- ask_user_choice、send_voice_message、Skill、渠道、子 Agent、插件和外部 MCP 工具仍按运行时条件注入,不计入 14 个默认固定工具。
|
||||
- 77 个退役实现/辅助文件直接删除,不保留 Agent 或 MCP 兼容副本。
|
||||
|
||||
@@ -133,6 +133,7 @@ provider 动态返回 namespaced action、参数约束、副作用等级及是
|
||||
| L6 原生工具收敛 | VERIFIED | L4,L5 | Agent 任务和人格工具合并为 action 工具;宿主/会话/渠道原生能力边界固定 |
|
||||
| L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 |
|
||||
| L8 收口与交付 | VERIFIED | L6,L7 | 旧代码、文档与架构基线已收口;静态检查和锁定全量测试已完成 |
|
||||
| L9 全 API 面审计与最终交付 | ACTIVE | L8 | 375 个 OpenAPI 操作逐路由归属、203 个网关合同与 72 个退出工具映射均由测试锁定;完成全量测试、80% 覆盖率门禁、提交推送和远端 CI 终态 |
|
||||
|
||||
## 5. L2 受控 API 网关约束
|
||||
|
||||
@@ -278,7 +279,7 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
|
||||
- CLI scheduler list/run 已改用 scheduler.list 与 scheduler.run operation,不再调用已删除工具名
|
||||
- 工具目录构造、Agent 图、HTTP/MCP direct 调用均强制唯一身份;同名插件/MCP 工具直接失败,不采用 first-wins
|
||||
- docs/mcp-api.md、docs/cli.md、命令规则、内置 Skill 文档和 Schema 导出清单已同步新合同
|
||||
- 宿主架构基线已更新:模块 982 降至 917,内部依赖边 8,430 降至 7,671,未新增 Application/Chain 到具体 Adapter 的直连
|
||||
- 宿主架构基线在 L8 首次更新为 917 个模块、7,671 条内部依赖边;L9 新增固定 API MCP 合同与外部服务工具 owner 后为 919 个模块、7,680 条内部依赖边,未新增 Application/Chain 到具体 Adapter 的直连
|
||||
- 受影响 Python 文件通过 Ruff、compileall 和 Pylint `--errors-only`;Schema 导出、架构基线与 `git diff --check` 均通过
|
||||
- 最终架构/OpenAPI/i18n/事件/package-root 回归 84 passed;Agent/MCP/CLI/Skill/架构大回归 1066 passed、插件与架构边界专项 124 passed、provider/gateway 专项 33 passed
|
||||
- 完整锁定测试 `uv run --locked --no-sync python tests/run.py` 完成:7554 passed、9 skipped、2 failed
|
||||
@@ -286,4 +287,32 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API
|
||||
- Agent 工具重构范围内没有遗留失败;L1-L8 全部退出,正式方案不保留旧工具、MCP 兼容或运行时切换开关
|
||||
- 当前变更尚未提交或推送
|
||||
|
||||
### 2026-08-31:Skill/MCP 自描述合同与系统设置收口
|
||||
|
||||
- 下载器 24 个 provider action 加 `instances.list` / `capabilities.list` 两个发现 action、媒体服务器 17 个 provider action 加同样两个发现 action、MoviePilot API 203 个 operation 均从运行时合同生成完整英文参数说明;外部 MCP `tools/list` 保留相同 oneOf、必填、类型、默认值、枚举和跨字段约束
|
||||
- 新增 admin-only `database_operation` MCP 工具,暴露 `tables`、`schema`、`query`、`write` 四个 action;内置 Agent 仍按需加载 `database-operation` Skill,不增加常驻业务工具
|
||||
- 数据库 Skill 已覆盖 27 张 ORM 表、`alembic_version` 和全部字段基线;每张表均说明用途、适合查询的场景和写入边界,运行时仍以 `tables` / `schema` 实际结果为准
|
||||
- Skill 文档生成器会对数据库说明与 ORM 元数据做双向缺项校验;新增、删除或改名表但未同步说明时生成直接失败,避免文档再次出现漏表或只有字段没有语义的问题
|
||||
- 系统设置继续由 `config.system.get/update` 统一承载,不恢复旧配置工具;Skill 不再复制易漂移的 `Settings` / `SystemConfigKey` 全量清单,而是要求先动态发现 `definition`(声明类型、当前形状、敏感性、允许操作、列表匹配字段和持久化位置),再按精确键更新
|
||||
- `Settings` 更新执行类型转换并持久化到 `app.env`;`SystemConfigKey` 通过配置服务写入数据库,保留插件 mutation 门禁、敏感值脱敏和配置变更事件
|
||||
- `app/agent/policy/api_mcp_schema.json` 明确为 OpenAPI + 固定 operation 合同生成的 MCP `tools/list` 制品,禁止手工维护;所有 operation、模型和字段必须具备具体英文说明,抽象占位文本或中文说明会由测试拒绝
|
||||
- API 权限保持双层硬门禁:`moviepilot_api` 按 operation 的 `required_role` 在发起 HTTP 前拒绝非管理员,最终 FastAPI 端点继续用真实当前用户令牌执行 superuser/manage/user 级鉴权;下载器、媒体服务器、数据库外部工具整体为 admin-only
|
||||
- 异步边界复核:内置 Skill 脚本经 asyncio subprocess 执行;外部服务/数据库 MCP 工具把同步脚本放入 downloader、mediaserver、db 分域线程池;`moviepilot_api` 使用异步 HTTP,不阻塞 Agent event loop
|
||||
- 当前阶段进入最终验证:聚焦 Agent/Skill/MCP 回归、全量锁定测试、80% 固定覆盖率 CI、提交推送与远端终态确认
|
||||
|
||||
### 2026-08-31 至 2026-09-01:L9 全 API 面审计与真实调用验证
|
||||
|
||||
- 从当前 FastAPI OpenAPI 生成 375 个 HTTP 操作的完整清单;每个路由明确归类为 `gateway`、`provider-skill`、`consolidated`、`alternate-auth-duplicate`、`transport_or_identity`、`stream_or_binary` 或 `ui_presentation`,生成器对任何未归类端点直接失败
|
||||
- `moviepilot_api` 扩展为 203 个稳定 operation,除 10 个音乐识别、探索、专辑、艺术家和缓存操作外,继续覆盖仪表盘、媒体发现与识别、站点全生命周期、订阅协作、存储维护、整理队列、工作流、种子缓存、数据库备份、规则与网络测试、插件运行管理、当前用户公开配置和使用统计;音乐支持艺术家到作品、作品到艺术家的双向浏览
|
||||
- 新增 `tests/test_agent_api_surface_audit.py`,逐项校验 OpenAPI 清单、固定路由、生成审计文档、MCP `oneOf`、英文字段说明和 Skill operation 章节完全一致
|
||||
- 将历史删除提交中的 72 个业务工具冻结为替代映射测试,逐项证明其 owner 是 203-operation API、下载器/媒体服务器 action 或统一 `agent_task` / `persona` 原生工具,并确认旧模块物理文件不存在
|
||||
- 实际执行数据库脚本 `tables` / `schema` / `SELECT 1`、下载器与媒体服务器 `instances` / `capabilities`,并直接运行三个结构化 service tool;本机未配置 provider 实例时返回空实例而不是配置读取错误
|
||||
- 通过临时本地 HTTP 服务实际执行 `MoviePilotApiTool -> MoviePilotApiExecutor -> GET /api/v1/site/agent` 完整链路,验证返回成功且普通用户投影不包含 cookie、API key、token 或 RSS 等认证字段
|
||||
- 修复完整 API Skill 超过原 64 KiB 运行时返回上限而被截断的问题:结果上限调整为 256 KiB,并以真实内置 Skill 加载测试确认 203 个 operation 均可见且 `truncated=false`
|
||||
- Skill 生成器同步 YAML `allowed-api-operations` 与正文目录,避免“文档有参数但运行时未授权”的双事实源漂移;MCP schema、Skill front matter、正文和注册表数量及集合完全一致
|
||||
- 为站点优先级、插件目录和工作流路径 ID 等原先不精确的输入补充类型模型或端点约束;固定路由占位符与 path schema 名称、required 状态由测试逐项校验
|
||||
- 受影响 Agent/Skill/MCP/OpenAPI/音乐/架构回归 247 passed;修复全量发现的工作流管理员门禁、插件分页默认值、模块命名治理、服务工具标签和 Schema 导出清单后,专项回归 40 passed
|
||||
- 完整锁定测试最终通过:7,614 passed、9 skipped;覆盖率按 CI 相同的 8 分片采集并合并,Application 81.88%、Domain 81.01%,通过固定 80% 门禁
|
||||
- 当前活动叶子保持 L9;下一步完成提交前差异审查、提交推送并检查远端 CI 终态
|
||||
|
||||
本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 917 / 7,671 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 919 / 7,680 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
+112
@@ -37,6 +37,118 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
|
||||
|
||||
MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。如果客户端缓存了工具列表,插件状态变化后需要让客户端重新请求 `tools/list`;无法手动刷新的客户端应重新连接 MCP 服务或新建会话。
|
||||
|
||||
## 3.1 结构化 Agent 工具与完整参数合同
|
||||
|
||||
`tools/list` 会为以下四个正式入口返回可直接校验的 JSON Schema。每个入口都按 operation/action 生成 `oneOf` 分支,分支中包含必填字段、类型、默认值、枚举、嵌套对象和互斥/至少一项等跨字段规则;外部 MCP 客户端可以在一次 `tools/call` 中完成参数构造,不需要猜测 URL、HTTP 方法或第三方 SDK 参数。
|
||||
|
||||
| MCP 工具 | 用途 | 参数合同来源 |
|
||||
| :--- | :--- | :--- |
|
||||
| `moviepilot_api` | MoviePilot 产品业务 API:媒体、搜索、订阅、下载、整理、站点、存储、调度、工作流、插件、过滤规则和系统配置 | `skills/moviepilot-api/SKILL.md`;运行时 schema 为 `app/agent/policy/api_mcp_schema.json` |
|
||||
| `downloader_operation` | qBittorrent、Transmission、rTorrent 原生任务、队列、文件、限速、标签和会话操作 | `skills/downloader-operation/SKILL.md` 与 `skills/downloader-operation/scripts/mp-downloader.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md` 与 `skills/database-operation/scripts/mp-db.py` 的 `ACTIONS` |
|
||||
|
||||
这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。
|
||||
|
||||
`app/agent/policy/api_mcp_schema.json` 是 `moviepilot_api` 的生成制品,不是设置项或 API 参数的手工事实源。`scripts/generate_agent_api_mcp_schema.py` 从当前 FastAPI OpenAPI、固定 operation 路由和 Agent 专用英文参数说明生成该文件;运行时直接读取它响应外部 MCP `tools/list`,测试会校验生成结果没有漂移。修改 API、请求模型或 operation 后应重新生成并提交该文件,不应直接编辑 JSON。
|
||||
|
||||
当前完整 FastAPI OpenAPI 包含 375 个 HTTP 操作,其中 203 个稳定业务操作进入
|
||||
`moviepilot_api`,使用 201 个固定路由模板:200 条 OpenAPI 路由直接匹配,另有 1 条只允许
|
||||
`tmdb`、`douban`、`bangumi`、`anilist` 四个来源的受限人物作品动态路由。每个 operation
|
||||
均同时具备固定 method/path、角色权限、副作用等级、确认与恢复策略、结果敏感性、英文用途说明,
|
||||
以及可直接提交的 path/query/body JSON Schema;Skill front matter、正文 operation 章节、运行时
|
||||
注册表和 MCP `tools/list` 的 203 个 `oneOf` 分支必须完全一致。
|
||||
|
||||
数量不相等是明确的安全与语义边界,而不是漏生成。当前 375 条路由均被审计并锁定为以下一种
|
||||
归属,审计生成器不再提供“未归类”兜底:
|
||||
|
||||
| 归属 | 数量 | Agent 使用方式 |
|
||||
| :--- | ---: | :--- |
|
||||
| `gateway` | 200 | 通过 `moviepilot_api` 的稳定 operation 和精确参数合同调用 |
|
||||
| `consolidated` | 72 | 通过同领域聚合 operation 调用,不复制数据源或前端专用路由 |
|
||||
| `provider-skill` | 11 | 通过下载器或媒体服务器 Skill 调用第三方 provider API |
|
||||
| `alternate-auth-duplicate` | 11 | 使用对应 bearer-authenticated gateway operation,不暴露 API_TOKEN 兼容副本 |
|
||||
| `transport_or_identity` | 66 | 由登录、令牌、MCP、会话、回调、健康检查等宿主传输/身份边界拥有 |
|
||||
| `stream_or_binary` | 10 | 由直接客户端处理流式日志、消息、文件、图片等非结构化响应 |
|
||||
| `ui_presentation` | 5 | 由前端或插件渲染面拥有,不作为业务 Agent operation |
|
||||
|
||||
逐路由归属见 `docs/architecture/agent-api-surface-audit.md`,并由
|
||||
`tests/test_agent_api_surface_audit.py` 对当前 OpenAPI、固定注册表、MCP schema、英文 Skill
|
||||
合同及全部非网关归属做漂移检查。任何新增端点必须先明确归属;对 Agent 开放时还必须补齐
|
||||
operation ID、权限、副作用、确认、恢复、结果敏感性及精确参数说明。
|
||||
|
||||
### `moviepilot_api` 调用形状
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "moviepilot_api",
|
||||
"arguments": {
|
||||
"operation_id": "subscription.list",
|
||||
"path_params": {},
|
||||
"query": {"page": 1, "count": 20},
|
||||
"body": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只允许传 `tools/list` 对应 operation 分支中声明的 `path_params`、`query` 和 `body` 字段。不得传 URL、认证头、API Token 或任意 HTTP 方法。
|
||||
|
||||
### `downloader_operation` / `mediaserver_operation` 调用形状
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "downloader_operation",
|
||||
"arguments": {
|
||||
"client": "main-qb",
|
||||
"action": "tasks.list",
|
||||
"arguments": {
|
||||
"status": "downloading",
|
||||
"offset": 0,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
媒体服务器将顶层实例字段改为 `server`,其余结构相同。`client`/`server` 可省略;具体 action 的 `arguments` 必须严格匹配对应 `oneOf` 分支。
|
||||
|
||||
### `database_operation` 调用形状
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "database_operation",
|
||||
"arguments": {
|
||||
"action": "query",
|
||||
"arguments": {
|
||||
"sql": "SELECT title, year FROM downloadhistory ORDER BY id DESC",
|
||||
"limit": 20,
|
||||
"write": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
数据库 action 参数为:
|
||||
|
||||
- `tables`:无参数,列出当前数据库实际表。
|
||||
- `schema`:必填 `table_name`,必须使用 `tables` 返回的精确名称。
|
||||
- `query`:`sql` 与 `file` 二选一;可选 `limit`(默认 100)和 `write`(默认 false)。默认只允许 `SELECT`、`WITH`、`EXPLAIN`。
|
||||
- `write`:`sql` 与 `file` 二选一,只允许单条写入或结构变更语句;必须已有明确授权。
|
||||
|
||||
数据库 ORM 当前维护的完整表清单与字段基线见 `skills/database-operation/SKILL.md` 的 `Core Tables`;运行时仍应先调用 `schema`,因为实际部署可能存在迁移差异或插件表。
|
||||
|
||||
### 系统设置、配置变量与数据库配置
|
||||
|
||||
系统设置统一使用 `moviepilot_api`,不需要恢复旧的 `query_system_settings` / `update_system_settings` 工具:
|
||||
|
||||
- `config.system.get` 同时查询 `Settings` 运行配置变量和 `SystemConfigKey` 数据库配置。可用 `setting_key` 精确读取,或用 `group` + `keyword` 发现键;单项默认返回完整值,多项默认只返回摘要。
|
||||
- 每个发现结果都返回动态 `definition`:声明类型、当前值形状、是否可空/敏感、允许的更新操作、列表默认匹配字段和持久化位置。Agent 应先发现定义,再按返回的精确键和形状调用更新。
|
||||
- `config.system.update` 支持 `replace`、`merge_dict`、`upsert_list_item`、`remove_list_item`。`Settings` 字段会执行类型转换并持久化到 `app.env`;`SystemConfigKey` 会经配置服务写入数据库并发布配置变更事件。
|
||||
- 敏感值默认脱敏;只有管理员明确要求时才传 `query.show_secrets=true`,并继续受宿主确认和保护输出策略约束。
|
||||
- `database_operation` 直接修改 `systemconfig` 只用于受控数据修复。普通配置修改不得绕过键注册、类型转换、插件 mutation 门禁和事件通知。
|
||||
|
||||
`skills/moviepilot-api/SKILL.md` 只维护稳定的发现与更新流程,不复制当前版本全部 `Settings` / `SystemConfigKey` 清单。真实键、类型和值形状由 `config.system.get` 运行时发现;MCP `tools/list` 的 `config.system.get/update` 分支负责说明发现参数和更新请求结构。
|
||||
|
||||
---
|
||||
|
||||
## 4. 客户端配置示例
|
||||
|
||||
@@ -86,6 +86,9 @@ to make the directory tree look symmetrical.
|
||||
| `app/agent/lifecycle.py` | Runtime generation admission, initialization, idle-session collection and bounded manager shutdown |
|
||||
| `app/agent/tasks.py` | Background prompts, durable scheduled-task execution and heartbeat wakeups |
|
||||
| `app/agent/orchestrator.py` | One `MoviePilotAgent` execution instance: prompt/tool/middleware assembly, model invocation, streaming and per-agent state |
|
||||
| `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input |
|
||||
| `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization |
|
||||
| `app/agent/tools/impl/service.py` | Admin-only external MCP wrappers for downloader, media-server and database Skill scripts; synchronous scripts run only through the Agent blocking executor |
|
||||
|
||||
`app.agent.AgentManager` remains the stable plugin-facing import and resolves lazily to
|
||||
`app.agent.manager.AgentManager`. Historical symbols formerly imported from
|
||||
@@ -94,6 +97,9 @@ orchestrator must not re-export manager/session implementations. The package roo
|
||||
uses a reviewed symbol whitelist and must not restore an unbounded `__getattr__`
|
||||
forwarder.
|
||||
|
||||
The policy and service modules above are host-internal owners. They are not SDK or
|
||||
Compat surfaces, and retired multiword module paths must not be restored as aliases.
|
||||
|
||||
Application services may use domain rules and runtime contracts. They own the
|
||||
persistence Protocol needed by a use case, but must not import `app.db`,
|
||||
SQLAlchemy, Session, Oper classes or concrete adapters. `app/db/adapters/`
|
||||
|
||||
@@ -14,7 +14,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS # noqa: E402
|
||||
from app.agent.policy.api_mcp_contract import build_api_mcp_input_schema # noqa: E402
|
||||
from app.agent.policy.mcp import build_api_mcp_input_schema # noqa: E402
|
||||
from app.api.apiv1 import api_router # noqa: E402
|
||||
|
||||
OUTPUT_PATH = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the complete OpenAPI-to-Agent surface audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES # noqa: E402
|
||||
from app.api.apiv1 import api_router # noqa: E402
|
||||
|
||||
JSON_OUTPUT = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.json"
|
||||
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.md"
|
||||
HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
TRANSPORT_TAGS = frozenset(
|
||||
{
|
||||
"agent",
|
||||
"anthropic",
|
||||
"auth",
|
||||
"llm",
|
||||
"login",
|
||||
"mcp",
|
||||
"message",
|
||||
"mfa",
|
||||
"notification",
|
||||
"openai",
|
||||
"user",
|
||||
"webhook",
|
||||
}
|
||||
)
|
||||
CONSOLIDATED_TAGS = frozenset(
|
||||
{
|
||||
"anilist",
|
||||
"bangumi",
|
||||
"discover",
|
||||
"douban",
|
||||
"recommend",
|
||||
"tmdb",
|
||||
}
|
||||
)
|
||||
PROVIDER_PATH_PREFIXES = (
|
||||
"/api/v1/download/",
|
||||
"/api/v1/mediaserver/",
|
||||
)
|
||||
CONSOLIDATED_ROUTE_OWNERS: dict[tuple[str, str], str] = {
|
||||
("POST", "/api/v1/download/"): "download.add",
|
||||
("GET", "/api/v1/plugin/installed"): "plugin.installed",
|
||||
("GET", "/api/v1/plugin/source/{plugin_id}/options"): "plugin.source.options",
|
||||
("GET", "/api/v1/plugin/{plugin_id}"): "plugin.config.get",
|
||||
("GET", "/api/v1/search/last"): "search.results",
|
||||
("GET", "/api/v1/search/media/{media_id}/stream"): "search.torrents",
|
||||
("GET", "/api/v1/search/subtitle/media/{media_id}/stream"): "subtitle.search.media",
|
||||
("GET", "/api/v1/search/subtitle/title/stream"): "subtitle.search.title",
|
||||
("GET", "/api/v1/search/title/stream"): "search.title",
|
||||
("GET", "/api/v1/site/"): "site.list",
|
||||
("GET", "/api/v1/site/cookie/{site_id}"): "site.cookie.update",
|
||||
("GET", "/api/v1/site/domain/{site_url}"): "site.list",
|
||||
("GET", "/api/v1/site/{site_id}"): "site.list",
|
||||
("POST", "/api/v1/storage/list"): "storage.list",
|
||||
("GET", "/api/v1/subscribe/list"): "subscription.list",
|
||||
("GET", "/api/v1/system/env"): "config.system.get",
|
||||
("POST", "/api/v1/system/env"): "config.system.update",
|
||||
("GET", "/api/v1/system/global"): "config.system.get",
|
||||
("GET", "/api/v1/system/setting/{key}"): "config.system.get",
|
||||
("POST", "/api/v1/system/setting/{key}"): "config.system.update",
|
||||
("GET", "/api/v1/transfer/now"): "scheduler.run",
|
||||
("GET", "/api/v1/workflow/"): "workflow.list",
|
||||
}
|
||||
STREAM_OR_BINARY_PATHS = frozenset(
|
||||
{
|
||||
"/api/v1/plugin/file/{plugin_id}/{filepath}",
|
||||
"/api/v1/site/icon/{site_id}",
|
||||
"/api/v1/storage/download",
|
||||
"/api/v1/storage/image",
|
||||
"/api/v1/system/cache/image",
|
||||
"/api/v1/system/img/{proxy}",
|
||||
"/api/v1/system/logging",
|
||||
"/api/v1/system/logging/download/{name}",
|
||||
"/api/v1/system/message",
|
||||
"/api/v1/system/progress/{process_type}",
|
||||
}
|
||||
)
|
||||
UI_PRESENTATION_PATHS = frozenset(
|
||||
{
|
||||
"/api/v1/plugin/dashboard/meta",
|
||||
"/api/v1/plugin/dashboard/{plugin_id}",
|
||||
"/api/v1/plugin/dashboard/{plugin_id}/{key}",
|
||||
"/api/v1/plugin/page/{plugin_id}",
|
||||
"/api/v1/plugin/sidebar_nav",
|
||||
}
|
||||
)
|
||||
EXPLICIT_TRANSPORT_PATHS = frozenset(
|
||||
{
|
||||
"/api/v1/plugin/remotes",
|
||||
"/api/v1/subscribe/seerr",
|
||||
"/api/v1/system/ping",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _gateway_routes() -> dict[tuple[str, str], list[str]]:
|
||||
"""Return exact HTTP routes and every stable gateway operation using them."""
|
||||
results: dict[tuple[str, str], list[str]] = defaultdict(list)
|
||||
for operation_id, route in API_OPERATION_ROUTES.items():
|
||||
results[(route.method.upper(), route.path)].append(operation_id)
|
||||
return {key: sorted(values) for key, values in results.items()}
|
||||
|
||||
|
||||
def _classify(
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
tags: list[str],
|
||||
gateway_routes: dict[tuple[str, str], list[str]],
|
||||
) -> tuple[str, str, str, list[str]]:
|
||||
"""Classify one OpenAPI operation into one reviewed Agent ownership boundary."""
|
||||
operations = gateway_routes.get((method, path), [])
|
||||
if operations:
|
||||
return (
|
||||
"gateway",
|
||||
"moviepilot-api",
|
||||
"Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
|
||||
operations,
|
||||
)
|
||||
primary_tag = tags[0] if tags else "untagged"
|
||||
if primary_tag in TRANSPORT_TAGS:
|
||||
return (
|
||||
"transport_or_identity",
|
||||
"host-runtime",
|
||||
"Authentication, protocol compatibility, conversation transport, callback, or account lifecycle endpoint; never recursively exposed as an Agent business action.",
|
||||
[],
|
||||
)
|
||||
consolidated_owner = CONSOLIDATED_ROUTE_OWNERS.get((method, path))
|
||||
if consolidated_owner:
|
||||
return (
|
||||
"consolidated",
|
||||
"moviepilot-api",
|
||||
f"This compatibility, broader-response, or UI route is represented by the safer stable operation {consolidated_owner}.",
|
||||
[consolidated_owner],
|
||||
)
|
||||
if path in STREAM_OR_BINARY_PATHS:
|
||||
return (
|
||||
"stream_or_binary",
|
||||
"host-transport",
|
||||
"Streaming, image, archive, or file response consumed by a direct client; the structured JSON Agent gateway does not proxy binary or unbounded streams.",
|
||||
[],
|
||||
)
|
||||
if path in UI_PRESENTATION_PATHS:
|
||||
return (
|
||||
"ui_presentation",
|
||||
"host-ui",
|
||||
"Plugin-rendered page, dashboard, or navigation metadata owned by the frontend presentation contract rather than an Agent business action.",
|
||||
[],
|
||||
)
|
||||
if path in EXPLICIT_TRANSPORT_PATHS:
|
||||
return (
|
||||
"transport_or_identity",
|
||||
"host-runtime",
|
||||
"Health, bootstrap, federation, or external webhook transport endpoint; it is not recursively callable as an Agent business action.",
|
||||
[],
|
||||
)
|
||||
if path.startswith(PROVIDER_PATH_PREFIXES):
|
||||
return (
|
||||
"provider-skill",
|
||||
"downloader-operation" if "/download/" in path else "mediaserver-operation",
|
||||
"Low-level provider behavior is exposed by the self-describing provider Skill; high-level MoviePilot operations remain in moviepilot-api.",
|
||||
[],
|
||||
)
|
||||
if primary_tag in CONSOLIDATED_TAGS:
|
||||
return (
|
||||
"consolidated",
|
||||
"moviepilot-api",
|
||||
"Source-specific or presentation-oriented route is represented by a stable aggregate search, detail, person, recommendation, or music operation instead of duplicating every frontend route.",
|
||||
[],
|
||||
)
|
||||
if path.endswith("2") or "/schedule2" in path or "/recognize2" in path or "/recognize_file2" in path:
|
||||
return (
|
||||
"alternate-auth-duplicate",
|
||||
"moviepilot-api",
|
||||
"API-token compatibility duplicate; the Agent uses the corresponding bearer-authenticated operation with its persisted user identity.",
|
||||
[],
|
||||
)
|
||||
raise ValueError(f"Unclassified OpenAPI operation: {method} {path}")
|
||||
|
||||
|
||||
def generate_audit() -> dict[str, Any]:
|
||||
"""Build a deterministic entry for every v1 OpenAPI HTTP operation."""
|
||||
app = FastAPI()
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
openapi = app.openapi()
|
||||
gateway_routes = _gateway_routes()
|
||||
entries = []
|
||||
for path, path_item in sorted(openapi.get("paths", {}).items()):
|
||||
for raw_method, operation in sorted(path_item.items()):
|
||||
method = raw_method.upper()
|
||||
if method not in HTTP_METHODS or not isinstance(operation, dict):
|
||||
continue
|
||||
tags = [str(tag) for tag in operation.get("tags") or []]
|
||||
disposition, owner, reason, operation_ids = _classify(
|
||||
method=method,
|
||||
path=path,
|
||||
tags=tags,
|
||||
gateway_routes=gateway_routes,
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"tags": tags,
|
||||
"summary": str(operation.get("summary") or ""),
|
||||
"disposition": disposition,
|
||||
"owner": owner,
|
||||
"operation_ids": operation_ids,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
counts = Counter(entry["disposition"] for entry in entries)
|
||||
matched_gateway_routes = {
|
||||
(entry["method"], entry["path"])
|
||||
for entry in entries
|
||||
if entry["disposition"] == "gateway"
|
||||
}
|
||||
dynamic_gateway_routes = [
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"operation_ids": operation_ids,
|
||||
"reason": (
|
||||
"The executor validates and expands this bounded source placeholder to one of "
|
||||
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
|
||||
),
|
||||
}
|
||||
for (method, path), operation_ids in sorted(gateway_routes.items())
|
||||
if (method, path) not in matched_gateway_routes
|
||||
]
|
||||
return {
|
||||
"openapi_operation_count": len(entries),
|
||||
"gateway_operation_count": len(API_OPERATION_ROUTES),
|
||||
"gateway_http_route_count": len(gateway_routes),
|
||||
"matched_gateway_http_route_count": len(matched_gateway_routes),
|
||||
"dynamic_gateway_routes": dynamic_gateway_routes,
|
||||
"disposition_counts": dict(sorted(counts.items())),
|
||||
"operations": entries,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(audit: dict[str, Any]) -> str:
|
||||
"""Render the complete audit as a reviewable architecture document."""
|
||||
lines = [
|
||||
"# MoviePilot Agent API Surface Audit",
|
||||
"",
|
||||
"> Generated from the v1 FastAPI OpenAPI document and the fixed Agent API registry.",
|
||||
"> Do not edit route rows manually; run `scripts/generate_agent_api_surface_audit.py`.",
|
||||
"",
|
||||
"## Result",
|
||||
"",
|
||||
f"- OpenAPI HTTP operations: **{audit['openapi_operation_count']}**",
|
||||
f"- Stable `moviepilot_api` operations: **{audit['gateway_operation_count']}**",
|
||||
f"- Exact HTTP routes used by the gateway: **{audit['gateway_http_route_count']}**",
|
||||
f"- OpenAPI routes matched directly by the gateway: **{audit['matched_gateway_http_route_count']}**",
|
||||
f"- Bounded dynamic gateway routes: **{len(audit['dynamic_gateway_routes'])}**",
|
||||
"- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.",
|
||||
"- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.",
|
||||
"",
|
||||
"## Dispositions",
|
||||
"",
|
||||
"| disposition | count | meaning |",
|
||||
"| :--- | ---: | :--- |",
|
||||
]
|
||||
meanings = {
|
||||
"gateway": "Approved structured MoviePilot Agent operation.",
|
||||
"provider-skill": "Low-level downloader or media-server capability owned by a provider Skill.",
|
||||
"consolidated": "Source/UI route represented by a stable aggregate Agent operation.",
|
||||
"alternate-auth-duplicate": "API-token compatibility duplicate of a bearer-authenticated capability.",
|
||||
"transport_or_identity": "Authentication, protocol, callback, account, or conversation transport boundary.",
|
||||
"stream_or_binary": "Streaming or binary response owned by a direct client transport.",
|
||||
"ui_presentation": "Frontend or plugin-rendered presentation contract.",
|
||||
}
|
||||
for disposition, count in audit["disposition_counts"].items():
|
||||
lines.append(f"| `{disposition}` | {count} | {meanings[disposition]} |")
|
||||
if audit["dynamic_gateway_routes"]:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Bounded Dynamic Routes",
|
||||
"",
|
||||
"| method | route template | operations | constraint |",
|
||||
"| :--- | :--- | :--- | :--- |",
|
||||
]
|
||||
)
|
||||
for item in audit["dynamic_gateway_routes"]:
|
||||
lines.append(
|
||||
f"| `{item['method']}` | `{item['path']}` | {', '.join(item['operation_ids'])} | {item['reason']} |"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Complete Route Inventory",
|
||||
"",
|
||||
"| method | path | tags | disposition | owner / operation | summary |",
|
||||
"| :--- | :--- | :--- | :--- | :--- | :--- |",
|
||||
]
|
||||
)
|
||||
for item in audit["operations"]:
|
||||
tags = ", ".join(item["tags"]) or "-"
|
||||
owner = ", ".join(item["operation_ids"]) or item["owner"]
|
||||
summary = item["summary"].replace("|", "\\|")
|
||||
lines.append(
|
||||
f"| `{item['method']}` | `{item['path']}` | {tags} | `{item['disposition']}` | {owner} | {summary} |"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Exposure Rule",
|
||||
"",
|
||||
"Every structured JSON business endpoint is either a stable gateway operation, a provider Skill capability, or an explicitly consolidated compatibility route. Authentication, webhook, stream, binary, and UI-presentation endpoints remain owned by their direct transport or frontend consumer and must not be made recursively callable by the Agent.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Write deterministic JSON and Markdown audit artifacts."""
|
||||
audit = generate_audit()
|
||||
JSON_OUTPUT.write_text(
|
||||
json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
MARKDOWN_OUTPUT.write_text(render_markdown(audit), encoding="utf-8")
|
||||
print(
|
||||
"generated "
|
||||
f"{JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,663 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate English Agent Skill contracts from runtime metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
"alembic_version": (
|
||||
"Records the Alembic migration revision currently applied to the database.",
|
||||
"Diagnosing startup migration failures or a database/code revision mismatch.",
|
||||
"Never edit it directly; advance or roll back revisions only through Alembic.",
|
||||
),
|
||||
"agentchat": (
|
||||
"Stores Web Agent and messaging-channel session indexes, titles, previews, and message snapshots.",
|
||||
"Tracing Agent history or context restoration by user, session, or update time.",
|
||||
"Owned by the Agent conversation service; do not rewrite message JSON, counters, or ownership.",
|
||||
),
|
||||
"agenttask": (
|
||||
"Stores one-shot or recurring Agent task definitions, triggers, and the latest execution summary.",
|
||||
"Inspecting task ownership, enablement, cron/run_at settings, and the latest result.",
|
||||
"Create, update, enable, disable, or delete tasks through the Agent task API.",
|
||||
),
|
||||
"agenttaskrun": (
|
||||
"Stores the input snapshot, status, timestamps, and result of each Agent task execution.",
|
||||
"Auditing one run or correlating a failure with task_id, run_id, and trigger source.",
|
||||
"Execution evidence owned by the task runner; never fabricate rows or edit run status.",
|
||||
),
|
||||
"downloadfailure": (
|
||||
"Stores stable fingerprints, media/torrent context, errors, and retry scheduling for failed downloads.",
|
||||
"Analyzing failure causes, retry counts, next retry time, and affected media or sites.",
|
||||
"Owned by download-failure compensation; retry or clean records through its business API.",
|
||||
),
|
||||
"downloadfiles": (
|
||||
"Maps downloader task hashes to full paths, save directories, relative files, and active state.",
|
||||
"Finding task files by downloader/download_hash or diagnosing savepath associations.",
|
||||
"Maintained by download and transfer flows; do not manually change state or path mappings.",
|
||||
),
|
||||
"downloadhistory": (
|
||||
"Stores media identity, torrent, downloader, user, and recognition context for submitted downloads.",
|
||||
"Reviewing download history or tracing a media identity or hash back to its source.",
|
||||
"Written by the download use case; delete or correct records through the download-history API.",
|
||||
),
|
||||
"mediaserveritem": (
|
||||
"Stores the local index and canonical media identity projected from media-server libraries.",
|
||||
"Checking library presence, server/library/path placement, and season information.",
|
||||
"This is a rebuildable projection; writes and cleanup belong to media-server synchronization.",
|
||||
),
|
||||
"message": (
|
||||
"Stores inbound and outbound messages, channels, content, attachments, users, and timestamps.",
|
||||
"Paging notification history, distinguishing direction, or tracing duplicates by source.",
|
||||
"Written by messaging and notification services; clean it through the message API or retention job.",
|
||||
),
|
||||
"outboxmessage": (
|
||||
"Stores externally visible side-effect intents committed atomically with business transactions.",
|
||||
"Diagnosing pending/processing/failed state, leases, attempts, and the last error.",
|
||||
"Owned by the Outbox Dispatcher state machine; never mark completion or delete undelivered events manually.",
|
||||
),
|
||||
"passkey": (
|
||||
"Stores WebAuthn/PassKey credentials, public keys, signature counters, and activation state.",
|
||||
"Authorized authentication diagnostics such as ownership, activation, and last use.",
|
||||
"Security-sensitive; manage it only through the PassKey API and never disclose credential material.",
|
||||
),
|
||||
"plugindata": (
|
||||
"Stores plugin-owned JSON values isolated by plugin_id and key.",
|
||||
"Diagnosing persistence or migration issues for one explicitly identified plugin and key.",
|
||||
"The plugin owns these values; prefer plugin capabilities or the plugin-data API.",
|
||||
),
|
||||
"pluginidentity": (
|
||||
"Stores trusted source, payload source, version, receipt, and CAS revision for a physical plugin package.",
|
||||
"Auditing source binding, package generation, payload application, or identity conflicts.",
|
||||
"Plugin supply-chain state owned exclusively by installation and update transactions.",
|
||||
),
|
||||
"plugininstallation": (
|
||||
"Stores plugin installation phase, membership target, identity revisions, and backup state.",
|
||||
"Diagnosing interrupted installations, rollback conditions, and package or backup presence.",
|
||||
"Owned by the plugin installation state machine; never advance phase or overwrite evidence manually.",
|
||||
),
|
||||
"site": (
|
||||
"Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.",
|
||||
"Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure.",
|
||||
"Contains cookies, API keys, and tokens; manage it through the site API.",
|
||||
),
|
||||
"siteicon": (
|
||||
"Caches site names, domains, icon URLs, and Base64 icon content.",
|
||||
"Diagnosing missing icons, incorrect domain mapping, or cache generation.",
|
||||
"Rebuildable cache owned by site-icon synchronization; direct writes are not recommended.",
|
||||
),
|
||||
"sitestatistic": (
|
||||
"Aggregates site request successes, failures, durations, latest state, and diagnostic notes.",
|
||||
"Comparing site availability, failure rate, and the most recent access state.",
|
||||
"Accumulated by site access statistics; never edit counters to conceal runtime behavior.",
|
||||
),
|
||||
"siteuserdata": (
|
||||
"Stores tracker account level, traffic, ratio, seeding, and unread-message data.",
|
||||
"Inspecting account state, traffic trends, seeding volume, and the latest collection error.",
|
||||
"A site-scraping projection refreshed by synchronization; do not edit it directly.",
|
||||
),
|
||||
"subscribe": (
|
||||
"Stores active movie, TV, or music subscriptions, filters, progress, and download targets.",
|
||||
"Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress.",
|
||||
"Create, update, search, or delete through the subscription API to preserve state-machine consistency.",
|
||||
),
|
||||
"subscribehistory": (
|
||||
"Stores snapshots of completed or archived subscriptions and their final filter state.",
|
||||
"Auditing historical subscriptions, media identity, completion criteria, and filter configuration.",
|
||||
"Generated by subscription completion and archival; restore or delete through its business API.",
|
||||
),
|
||||
"systemconfig": (
|
||||
"Stores JSON business configuration values keyed by SystemConfigKey.",
|
||||
"Verifying the physical value only when the managed settings API behaves unexpectedly.",
|
||||
"Use config.system.get/update first; direct writes bypass validation, events, and plugin admission.",
|
||||
),
|
||||
"transferexecutionstep": (
|
||||
"Stores intent, attempt identity, state, and result evidence for each durable transfer operation.",
|
||||
"Diagnosing stuck, failed, or repeated steps by task_id or operation_id.",
|
||||
"Owned by the transfer execution state machine and lease CAS; never force state transitions manually.",
|
||||
),
|
||||
"transferhistory": (
|
||||
"Stores transfer source, destination, mode, media identity, download linkage, and outcome.",
|
||||
"Reviewing success/failure history, destination paths, media classification, and download linkage.",
|
||||
"Written by transfer settlement; delete or retry through transfer-history business APIs.",
|
||||
),
|
||||
"transferpending": (
|
||||
"Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state.",
|
||||
"Diagnosing restart recovery, expired leases, retry_wait, terminal failures, or manual review.",
|
||||
"Core durable state machine advanced only by planning, execution, retry, and review services.",
|
||||
),
|
||||
"transfersettlementreceipt": (
|
||||
"Stores immutable terminal settlement receipts with contiguous revisions per transfer task.",
|
||||
"Verifying that history, pending deletion, and execution fingerprints were settled reliably.",
|
||||
"Idempotency and audit evidence; append revisions only and never overwrite or delete old receipts.",
|
||||
),
|
||||
"user": (
|
||||
"Stores user accounts, password hashes, administrator state, OTP, permissions, and preferences.",
|
||||
"Authorized diagnostics of account state, permissions, or authentication configuration.",
|
||||
"Security-sensitive; manage through user, permission, password, and two-factor APIs.",
|
||||
),
|
||||
"userconfig": (
|
||||
"Stores per-user JSON configuration isolated by username and key.",
|
||||
"Inspecting UI preferences, message clear cursors, or other personalized state.",
|
||||
"Modify through the owning user or messaging API to preserve key semantics.",
|
||||
),
|
||||
"workflow": (
|
||||
"Stores workflow definitions, triggers, action graphs, execution context, and runtime state.",
|
||||
"Inspecting scheduled/event workflows, pause state, current action, run count, and failures.",
|
||||
"Create, modify, run, pause, or reset through the workflow API.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _load_json_schema() -> dict[str, Any]:
|
||||
"""Load the generated moviepilot_api MCP schema."""
|
||||
path = PROJECT_ROOT / "app/agent/policy/api_mcp_schema.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("The API MCP schema must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def _schema_type(schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> str:
|
||||
"""Compress JSON Schema into a readable Skill type expression."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
return reference.rsplit("/", 1)[-1]
|
||||
if isinstance(schema.get("const"), (str, int, float, bool)):
|
||||
return f"{schema.get('type', 'value')}={schema['const']}"
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list):
|
||||
return f"{schema.get('type', 'value')}({','.join(map(str, enum))})"
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "array":
|
||||
items = schema.get("items")
|
||||
item_type = _schema_type(items, definitions) if isinstance(items, Mapping) else "value"
|
||||
return f"array<{item_type}>"
|
||||
if schema_type:
|
||||
return str(schema_type)
|
||||
any_of = schema.get("anyOf")
|
||||
if isinstance(any_of, list):
|
||||
return "|".join(
|
||||
_schema_type(item, definitions)
|
||||
for item in any_of
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
if "properties" in schema or schema.get("additionalProperties") is not None:
|
||||
return "object"
|
||||
return "value"
|
||||
|
||||
|
||||
def _field_suffix(schema: Mapping[str, Any]) -> str:
|
||||
"""Format defaults, ranges, and collection constraints."""
|
||||
suffix: list[str] = []
|
||||
if "default" in schema:
|
||||
suffix.append(f"default `{schema['default']}`")
|
||||
if "minimum" in schema:
|
||||
suffix.append(f"minimum `{schema['minimum']}`")
|
||||
if "maximum" in schema:
|
||||
suffix.append(f"maximum `{schema['maximum']}`")
|
||||
if "minLength" in schema:
|
||||
suffix.append(f"minimum length `{schema['minLength']}`")
|
||||
if "minItems" in schema:
|
||||
suffix.append(f"minimum items `{schema['minItems']}`")
|
||||
return f"; {'; '.join(suffix)}" if suffix else ""
|
||||
|
||||
|
||||
def _schema_fields(
|
||||
schema: Mapping[str, Any],
|
||||
definitions: Mapping[str, Any],
|
||||
) -> list[tuple[str, str, str, bool]]:
|
||||
"""Extract fields, types, descriptions, and required markers."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
schema = definitions.get(reference.rsplit("/", 1)[-1], {})
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
return []
|
||||
required = set(schema.get("required") or [])
|
||||
fields = []
|
||||
for name, raw_schema in properties.items():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
fields.append(
|
||||
(
|
||||
str(name),
|
||||
_schema_type(raw_schema, definitions),
|
||||
str(raw_schema.get("description") or ""),
|
||||
str(name) in required,
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def _resolve_schema(
|
||||
schema: Mapping[str, Any],
|
||||
definitions: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
"""Resolve a local JSON Schema reference into an enumerable object."""
|
||||
reference = schema.get("$ref")
|
||||
if isinstance(reference, str):
|
||||
resolved = definitions.get(reference.rsplit("/", 1)[-1])
|
||||
if isinstance(resolved, Mapping):
|
||||
return resolved
|
||||
return schema
|
||||
|
||||
|
||||
def _render_api_docs() -> str:
|
||||
"""Render the complete MoviePilot API operation and field contract."""
|
||||
schema = _load_json_schema()
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), Mapping) else {}
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS
|
||||
|
||||
specs = {spec.operation_id: spec for spec in API_OPERATION_SPECS}
|
||||
lines = [
|
||||
"## Operation Catalog",
|
||||
"",
|
||||
"The operations, HTTP methods, routes, and path/query/body fields below exactly match external MCP `tools/list`.",
|
||||
"A field name ending in `*` is required. Omit an empty bucket or send `{}`. Referenced body models are expanded below.",
|
||||
"",
|
||||
]
|
||||
for operation_id in sorted(API_OPERATION_ROUTES):
|
||||
route = API_OPERATION_ROUTES[operation_id]
|
||||
branch = next(
|
||||
item
|
||||
for item in schema["oneOf"]
|
||||
if item["properties"]["operation_id"].get("const") == operation_id
|
||||
)
|
||||
description = str(branch.get("description") or "")
|
||||
effect = specs[operation_id].effect.value
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{operation_id}`",
|
||||
f"`{route.method} {route.path}`; policy effect: `{effect}`.",
|
||||
f"Purpose: {description.split(' Method:', 1)[0].strip()}",
|
||||
]
|
||||
)
|
||||
for bucket in ("path_params", "query", "body"):
|
||||
bucket_schema = branch["properties"].get(bucket)
|
||||
if not isinstance(bucket_schema, Mapping):
|
||||
lines.append(f"- `{bucket}`: none")
|
||||
continue
|
||||
resolved_bucket = _resolve_schema(bucket_schema, definitions)
|
||||
fields = _schema_fields(resolved_bucket, definitions)
|
||||
if not fields:
|
||||
if resolved_bucket.get("type") != "object":
|
||||
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
|
||||
description_text = str(resolved_bucket.get("description") or "")
|
||||
if not description_text:
|
||||
raise ValueError(
|
||||
f"API Skill scalar guidance is missing: {operation_id}.{bucket}"
|
||||
)
|
||||
lines.append(
|
||||
f"- `{bucket}{required_mark}` ({_schema_type(resolved_bucket, definitions)}): "
|
||||
f"{description_text}"
|
||||
)
|
||||
continue
|
||||
dynamic_description = str(resolved_bucket.get("description") or "")
|
||||
if resolved_bucket.get("additionalProperties") is True and dynamic_description:
|
||||
required_mark = "*" if bucket in set(branch.get("required") or []) else ""
|
||||
lines.append(f"- `{bucket}{required_mark}` (object): {dynamic_description}")
|
||||
continue
|
||||
body_ref = bucket_schema.get("$ref")
|
||||
label = (
|
||||
body_ref.rsplit("/", 1)[-1]
|
||||
if isinstance(body_ref, str)
|
||||
else "empty object"
|
||||
)
|
||||
lines.append(f"- `{bucket}`: `{label}` with no direct fields")
|
||||
continue
|
||||
rendered = []
|
||||
for name, field_type, description_text, required in fields:
|
||||
required_mark = "*" if required else ""
|
||||
if not description_text:
|
||||
raise ValueError(
|
||||
f"API Skill field guidance is missing: {operation_id}.{bucket}.{name}"
|
||||
)
|
||||
raw_schema = resolved_bucket["properties"][name]
|
||||
rendered.append(
|
||||
f"`{name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
|
||||
)
|
||||
lines.append(f"- `{bucket}`: " + "; ".join(rendered))
|
||||
lines.append("")
|
||||
|
||||
lines.extend(["### Referenced Body Models", ""])
|
||||
referenced: set[str] = set()
|
||||
for branch in schema["oneOf"]:
|
||||
for bucket in ("path_params", "query", "body"):
|
||||
bucket_schema = branch["properties"].get(bucket)
|
||||
if not isinstance(bucket_schema, Mapping):
|
||||
continue
|
||||
resolved_bucket = _resolve_schema(bucket_schema, definitions)
|
||||
for raw_schema in (resolved_bucket.get("properties") or {}).values():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
refs = [raw_schema.get("$ref")]
|
||||
refs.extend(
|
||||
item.get("$ref")
|
||||
for item in raw_schema.get("anyOf", [])
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
for reference in refs:
|
||||
if isinstance(reference, str) and reference.startswith("#/$defs/"):
|
||||
referenced.add(reference.rsplit("/", 1)[-1])
|
||||
pending = list(referenced)
|
||||
while pending:
|
||||
name = pending.pop()
|
||||
model = definitions.get(name)
|
||||
if not isinstance(model, Mapping):
|
||||
continue
|
||||
for raw_schema in (model.get("properties") or {}).values():
|
||||
if not isinstance(raw_schema, Mapping):
|
||||
continue
|
||||
references = [raw_schema.get("$ref")]
|
||||
references.extend(
|
||||
item.get("$ref")
|
||||
for item in raw_schema.get("anyOf", [])
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
for reference in references:
|
||||
if isinstance(reference, str) and reference.startswith("#/$defs/"):
|
||||
child = reference.rsplit("/", 1)[-1]
|
||||
if child not in referenced:
|
||||
referenced.add(child)
|
||||
pending.append(child)
|
||||
|
||||
for name in sorted(referenced):
|
||||
model = definitions.get(name)
|
||||
if not isinstance(model, Mapping):
|
||||
continue
|
||||
lines.append(f"#### `{name}`")
|
||||
model_description = str(model.get("description") or "")
|
||||
if not model_description:
|
||||
raise ValueError(f"API Skill model guidance is missing: {name}")
|
||||
lines.append(model_description)
|
||||
fields = _schema_fields(model, definitions)
|
||||
if not fields:
|
||||
lines.append("This runtime model has no directly writable fields.")
|
||||
else:
|
||||
for field_name, field_type, description_text, required in fields:
|
||||
required_mark = "*" if required else ""
|
||||
raw_schema = model["properties"][field_name]
|
||||
if not description_text:
|
||||
raise ValueError(f"API Skill model field guidance is missing: {name}.{field_name}")
|
||||
lines.append(
|
||||
f"- `{field_name}{required_mark}` ({field_type}{_field_suffix(raw_schema)}): {description_text}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append(_render_system_settings_docs().rstrip())
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
"## Operation Order And Failure Handling",
|
||||
"",
|
||||
"1. Select the operation first, then place each value in its documented bucket. Never move query fields into path_params or send undeclared fields.",
|
||||
"2. Reuse the exact `media_source` + `media_id` pair returned by search. For music, also preserve `music_type`.",
|
||||
"3. Downloads, transfers, configuration/rule/plugin writes, scheduler/workflow runs, and deletions have side effects; obtain confirmation and inspect the result.",
|
||||
"4. `success=false`, HTTP errors, validation errors, and empty results are real outcomes. Never report them as success.",
|
||||
"5. Use `database-operation`, `downloader-operation`, or `mediaserver-operation` for their native capabilities. Never bypass the gateway with an arbitrary URL.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_service_docs(script_path: Path, title: str) -> str:
|
||||
"""Render one downloader or media-server action contract."""
|
||||
namespace = runpy.run_path(str(script_path))
|
||||
actions = namespace.get("ACTIONS")
|
||||
if not isinstance(actions, Mapping):
|
||||
raise ValueError(f"{script_path} does not expose ACTIONS")
|
||||
lines = [
|
||||
"## Complete Action Contract",
|
||||
"",
|
||||
f"This is the complete {title} 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.",
|
||||
"",
|
||||
"| action | Purpose and argument summary |",
|
||||
"| :--- | :--- |",
|
||||
]
|
||||
for name, spec in sorted(actions.items()):
|
||||
contract = spec.to_dict(name)
|
||||
argument_names = [
|
||||
f"`{argument['name']}{'*' if argument['required'] else ''}`"
|
||||
for argument in contract["arguments"]
|
||||
]
|
||||
summary = contract["description"]
|
||||
if argument_names:
|
||||
summary += "; arguments: " + ", ".join(argument_names)
|
||||
else:
|
||||
summary += "; no arguments"
|
||||
lines.append(f"| `{name}` | {summary} |")
|
||||
lines.append("")
|
||||
for name, spec in sorted(actions.items()):
|
||||
contract = spec.to_dict(name)
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{name}`",
|
||||
f"{contract['description']} Effect: `{contract['effect']}`. Providers: `{', '.join(contract['providers'])}`.",
|
||||
]
|
||||
)
|
||||
if not contract["arguments"]:
|
||||
lines.append("- `arguments`: `{}`")
|
||||
else:
|
||||
for argument in contract["arguments"]:
|
||||
required_mark = "*" if argument["required"] else ""
|
||||
extras = []
|
||||
if "default" in argument:
|
||||
extras.append(f"default `{argument['default']}`")
|
||||
if argument.get("enum"):
|
||||
extras.append(f"allowed values `{','.join(map(str, argument['enum']))}`")
|
||||
suffix = f"; {'; '.join(extras)}" if extras else ""
|
||||
lines.append(
|
||||
f"- `{argument['name']}{required_mark}` ({argument['type']}{suffix}): {argument['description']}"
|
||||
)
|
||||
for rule in contract.get("argument_rules") or []:
|
||||
lines.append(f"- Rule: {rule}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_database_tables() -> str:
|
||||
"""Render all ORM tables, purposes, and access boundaries."""
|
||||
from app.db.base import Base
|
||||
from app.db.models import load_all_models
|
||||
|
||||
load_all_models()
|
||||
table_names = {"alembic_version", *Base.metadata.tables}
|
||||
missing_guides = table_names - DATABASE_TABLE_GUIDES.keys()
|
||||
stale_guides = DATABASE_TABLE_GUIDES.keys() - table_names
|
||||
if missing_guides or stale_guides:
|
||||
raise ValueError(
|
||||
"Database table guidance does not match ORM metadata: "
|
||||
f"missing={sorted(missing_guides)}, stale={sorted(stale_guides)}"
|
||||
)
|
||||
lines = [
|
||||
"## Core Tables",
|
||||
"",
|
||||
"`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.",
|
||||
"",
|
||||
]
|
||||
for table_name in sorted(table_names):
|
||||
purpose, query_usage, write_boundary = DATABASE_TABLE_GUIDES[table_name]
|
||||
if table_name == "alembic_version":
|
||||
columns = "`version_num`"
|
||||
else:
|
||||
table = Base.metadata.tables[table_name]
|
||||
columns = ", ".join(f"`{column.name}`" for column in table.columns)
|
||||
lines.extend(
|
||||
[
|
||||
f"### `{table_name}`",
|
||||
f"- Purpose: {purpose}",
|
||||
f"- Useful queries: {query_usage}",
|
||||
f"- Write boundary: {write_boundary}",
|
||||
f"- Columns: {columns}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"## 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.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _render_system_settings_docs() -> str:
|
||||
"""Render dynamic system-setting discovery and update guidance."""
|
||||
return "\n".join([
|
||||
"## System Settings Contract",
|
||||
"",
|
||||
"Do not enumerate setting keys in this Skill. Settings change as MoviePilot evolves, so use `config.system.get` as the runtime discovery operation before updating an unfamiliar key.",
|
||||
"",
|
||||
"| `source` | Contents | Persistence |",
|
||||
"| :--- | :--- | :--- |",
|
||||
"| `settings` | Runtime `Settings` fields such as APP_DOMAIN or LLM_MODEL | Type-converted and persisted to `app.env`, then applied to the current process |",
|
||||
"| `systemconfig` | Database-backed business configuration such as downloaders, media servers, directories, and notifications | Written through the configuration service with plugin admission and change events |",
|
||||
"",
|
||||
"The `systemconfig` database table is only the physical store for the second source. Use `config.system.get/update` for normal reads and writes. Direct SQL is reserved for an explicitly authorized repair when the managed API cannot complete the operation.",
|
||||
"",
|
||||
"### Discover definitions",
|
||||
"",
|
||||
"1. Call `config.system.get` with `query={\"group\":\"settings\",\"keyword\":\"LLM\"}` or another group/keyword. Discovery defaults to summaries instead of full values.",
|
||||
"2. Each returned setting includes `setting_key`, `source`, `group`, `label`, and a `definition` object with `declared_type`, current `value_shape`, `nullable`, `sensitive`, allowed `update_operations`, `default_match_field`, and `persistence`.",
|
||||
"3. Read one exact value with `query={\"setting_key\":\"LLM_MODEL\"}`. Exact-key reads include the value by default.",
|
||||
"4. Use `show_secrets=true` only when an administrator explicitly requests the plaintext value; secret reads remain confirmation-protected.",
|
||||
"",
|
||||
"### Update settings",
|
||||
"",
|
||||
"Choose an operation listed in the discovered setting definition, then send it in `body`:",
|
||||
"",
|
||||
"| operation | Fields | Meaning |",
|
||||
"| :--- | :--- | :--- |",
|
||||
"| `replace` | `setting_key*`, `value` | Replace the complete scalar, list, or object value |",
|
||||
"| `merge_dict` | `setting_key*`, `value`; optional `remove_keys` | Shallow-merge an object and optionally remove keys |",
|
||||
"| `upsert_list_item` | `setting_key*`, `value`; optional `match_field`, `match_value` | Replace a matched list item or append it when absent |",
|
||||
"| `remove_list_item` | `setting_key*`, `value` or `match_value`; optional `match_field` | Remove one matched list item without replacing the list |",
|
||||
"",
|
||||
"After every update, call `config.system.get` again with the exact setting_key and verify the saved value. Do not guess a key, value shape, list match field, or update operation when discovery can return it.",
|
||||
"",
|
||||
]).rstrip() + "\n"
|
||||
|
||||
|
||||
def _sync_api_frontmatter(text: str) -> str:
|
||||
"""Synchronize the API Skill authorization list with the fixed operation registry."""
|
||||
from app.agent.policy.api import list_api_operation_ids
|
||||
|
||||
operation_text = " ".join(list_api_operation_ids())
|
||||
wrapped = textwrap.wrap(
|
||||
operation_text,
|
||||
width=96,
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False,
|
||||
)
|
||||
replacement = "allowed-api-operations: >-\n" + "\n".join(f" {line}" for line in wrapped) + "\n"
|
||||
lines = text.splitlines(keepends=True)
|
||||
frontmatter_end = next(
|
||||
(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"),
|
||||
None,
|
||||
)
|
||||
if frontmatter_end is None:
|
||||
raise ValueError("moviepilot-api Skill frontmatter closing marker is missing")
|
||||
field_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines[:frontmatter_end])
|
||||
if line.startswith("allowed-api-operations:")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if field_index is None:
|
||||
raise ValueError("moviepilot-api Skill frontmatter operation list is missing")
|
||||
field_end = field_index + 1
|
||||
while field_end < frontmatter_end and lines[field_end].startswith((" ", "\t")):
|
||||
field_end += 1
|
||||
lines[field_index:field_end] = [replacement]
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def _replace_section(text: str, heading: str, replacement: str, next_heading: str | None = None) -> str:
|
||||
"""Replace one Markdown section from a heading to the next heading."""
|
||||
start = text.index(heading)
|
||||
if next_heading is None:
|
||||
return text[:start].rstrip() + "\n\n" + replacement
|
||||
end = text.index(next_heading, start)
|
||||
return text[:start].rstrip() + "\n\n" + replacement + "\n" + text[end:]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate complete contracts for four built-in Skills."""
|
||||
api_path = PROJECT_ROOT / "skills/moviepilot-api/SKILL.md"
|
||||
api_text = api_path.read_text(encoding="utf-8")
|
||||
api_text = _sync_api_frontmatter(api_text)
|
||||
api_path.write_text(
|
||||
_replace_section(api_text, "## Operation Catalog", _render_api_docs()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
downloader_path = PROJECT_ROOT / "skills/downloader-operation/SKILL.md"
|
||||
downloader_text = downloader_path.read_text(encoding="utf-8")
|
||||
downloader_path.write_text(
|
||||
_replace_section(
|
||||
downloader_text,
|
||||
"## Complete Action Contract",
|
||||
_render_service_docs(
|
||||
PROJECT_ROOT / "skills/downloader-operation/scripts/mp-downloader.py",
|
||||
"Downloader Operation",
|
||||
),
|
||||
"## Verification",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mediaserver_path = PROJECT_ROOT / "skills/mediaserver-operation/SKILL.md"
|
||||
mediaserver_text = mediaserver_path.read_text(encoding="utf-8")
|
||||
mediaserver_path.write_text(
|
||||
_replace_section(
|
||||
mediaserver_text,
|
||||
"## Complete Action Contract",
|
||||
_render_service_docs(
|
||||
PROJECT_ROOT / "skills/mediaserver-operation/scripts/mp-mediaserver.py",
|
||||
"Media Server Operation",
|
||||
),
|
||||
"## Safety And Verification",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
database_path = PROJECT_ROOT / "skills/database-operation/SKILL.md"
|
||||
database_text = database_path.read_text(encoding="utf-8")
|
||||
database_path.write_text(
|
||||
_replace_section(
|
||||
database_text,
|
||||
"## Core Tables",
|
||||
_render_database_tables(),
|
||||
"## Common Queries",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("generated four Agent Skill contracts")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "Magnet、torrent 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:
|
||||
|
||||
@@ -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", "媒体库或父条目 ID;Navidrome 可省略并使用 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", "按用户名读取可见媒体库;仅 Emby、Jellyfin、ZSpace 支持。"),
|
||||
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 外必须提供 parent;Navidrome 忽略 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 外必须提供 parent;Navidrome 省略时使用 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", "专辑名称;title、artist、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."),
|
||||
),
|
||||
("title、artist、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", "按用户名读取;仅 Emby、Jellyfin、ZSpace 支持。")),
|
||||
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", "按用户名读取;仅 Emby、Jellyfin、ZSpace 支持。")),
|
||||
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:string、year:string|integer、type:电影|电视剧|音乐、category:string、target_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:
|
||||
|
||||
+1619
-98
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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())
|
||||
+14
-3
@@ -1089,8 +1089,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 7671,
|
||||
"edge_sha256": "cf48bcc1cf83bb9a70be9d15fd0d5a094cb63016178ce0f5f95cb6f1b1c5e654",
|
||||
"edge_count": 7680,
|
||||
"edge_sha256": "e587b2c539a0100a96f73ec7daf1634671e2570e9c89114774fdcd26b9072aac",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1692,6 +1692,7 @@
|
||||
"app.agent.tools.factory -> app.agent.tools.impl.send_local_file",
|
||||
"app.agent.tools.factory -> app.agent.tools.impl.send_message",
|
||||
"app.agent.tools.factory -> app.agent.tools.impl.send_voice_message",
|
||||
"app.agent.tools.factory -> app.agent.tools.impl.service",
|
||||
"app.agent.tools.factory -> app.agent.tools.impl.write_file",
|
||||
"app.agent.tools.factory -> app.application",
|
||||
"app.agent.tools.factory -> app.application.agent",
|
||||
@@ -1724,6 +1725,7 @@
|
||||
"app.agent.tools.impl.api -> app.agent.api.executor",
|
||||
"app.agent.tools.impl.api -> app.agent.policy",
|
||||
"app.agent.tools.impl.api -> app.agent.policy.api",
|
||||
"app.agent.tools.impl.api -> app.agent.policy.contracts",
|
||||
"app.agent.tools.impl.api -> app.agent.tools",
|
||||
"app.agent.tools.impl.api -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.api -> app.agent.tools.tags",
|
||||
@@ -1853,6 +1855,12 @@
|
||||
"app.agent.tools.impl.send_voice_message -> app.runtime.settings",
|
||||
"app.agent.tools.impl.send_voice_message -> app.schemas",
|
||||
"app.agent.tools.impl.send_voice_message -> app.schemas.message",
|
||||
"app.agent.tools.impl.service -> app.agent",
|
||||
"app.agent.tools.impl.service -> app.agent.tools",
|
||||
"app.agent.tools.impl.service -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.service -> app.agent.tools.tags",
|
||||
"app.agent.tools.impl.service -> app.runtime",
|
||||
"app.agent.tools.impl.service -> app.runtime.settings",
|
||||
"app.agent.tools.impl.write_file -> app.agent",
|
||||
"app.agent.tools.impl.write_file -> app.agent.tools",
|
||||
"app.agent.tools.impl.write_file -> app.agent.tools.base",
|
||||
@@ -2822,6 +2830,7 @@
|
||||
"app.api.endpoints.workflow -> app.chain",
|
||||
"app.api.endpoints.workflow -> app.chain.workflow",
|
||||
"app.api.endpoints.workflow -> app.schemas",
|
||||
"app.api.endpoints.workflow -> app.schemas.common",
|
||||
"app.api.endpoints.workflow -> app.schemas.response",
|
||||
"app.api.endpoints.workflow -> app.schemas.types",
|
||||
"app.api.endpoints.workflow -> app.schemas.workflow",
|
||||
@@ -8764,7 +8773,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 917,
|
||||
"module_count": 919,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -8856,6 +8865,7 @@
|
||||
"app.agent.policy",
|
||||
"app.agent.policy.api",
|
||||
"app.agent.policy.contracts",
|
||||
"app.agent.policy.mcp",
|
||||
"app.agent.policy.orchestrator",
|
||||
"app.agent.policy.registry",
|
||||
"app.agent.policy.sanitizer",
|
||||
@@ -8891,6 +8901,7 @@
|
||||
"app.agent.tools.impl.send_local_file",
|
||||
"app.agent.tools.impl.send_message",
|
||||
"app.agent.tools.impl.send_voice_message",
|
||||
"app.agent.tools.impl.service",
|
||||
"app.agent.tools.impl.write_file",
|
||||
"app.agent.tools.manager",
|
||||
"app.agent.tools.tags",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -11,30 +12,252 @@ from app.agent.policy import (
|
||||
PrincipalRole,
|
||||
)
|
||||
from app.agent.policy.api import (
|
||||
API_EXTENDED_OPERATION_SPECS,
|
||||
API_FIRST_BATCH_OPERATION_SPECS,
|
||||
API_MUSIC_OPERATION_SPECS,
|
||||
API_OPERATION_ROUTES,
|
||||
API_OPERATION_SPECS,
|
||||
API_PARITY_OPERATION_SPECS,
|
||||
API_SYSTEM_OPERATION_SPECS,
|
||||
)
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.agent_task import AgentTaskTool
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.execute_command import ExecuteCommandTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
|
||||
|
||||
def test_api_operation_registry_matches_migration_batches() -> None:
|
||||
"""API 操作注册表必须覆盖两批 operation 且每项具有固定路由。"""
|
||||
assert len(API_FIRST_BATCH_OPERATION_SPECS) == 44
|
||||
assert len(API_FIRST_BATCH_OPERATION_SPECS) == 52
|
||||
assert len(API_PARITY_OPERATION_SPECS) == 15
|
||||
assert len(API_OPERATION_SPECS) == 59
|
||||
assert len(API_MUSIC_OPERATION_SPECS) == 10
|
||||
assert len(API_SYSTEM_OPERATION_SPECS) == 7
|
||||
assert len(API_EXTENDED_OPERATION_SPECS) == 119
|
||||
assert len(API_OPERATION_SPECS) == 203
|
||||
assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES)
|
||||
assert {
|
||||
"download.list",
|
||||
"download.update",
|
||||
"download.delete",
|
||||
"downloaders.list",
|
||||
"library.latest",
|
||||
}.isdisjoint(API_OPERATION_ROUTES)
|
||||
assert {
|
||||
"plugin.source.options",
|
||||
"plugin.source.install",
|
||||
"plugin.source.change",
|
||||
"download.tasks.active",
|
||||
"download.clients",
|
||||
"download.paths",
|
||||
"download.history.list",
|
||||
"library.latest",
|
||||
"system.versions",
|
||||
"system.update.status",
|
||||
"system.update.check",
|
||||
"system.update.download",
|
||||
"system.restart",
|
||||
"system.update.install",
|
||||
"system.upgrade.dev",
|
||||
"dashboard.system",
|
||||
"media.sources",
|
||||
"search.title",
|
||||
"site.add",
|
||||
"subscription.get",
|
||||
"storage.rename",
|
||||
"transfer.manual_reviews",
|
||||
"workflow.create",
|
||||
"torrent.cache.get",
|
||||
"database.backups.list",
|
||||
"system.module.list",
|
||||
"plugin.clone",
|
||||
}.issubset(API_OPERATION_ROUTES)
|
||||
|
||||
|
||||
def test_music_operations_expose_bidirectional_artist_album_navigation() -> None:
|
||||
"""音乐 Skill 必须完整暴露作品到作者、作者到作品及关联浏览合同。"""
|
||||
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
|
||||
branches = {
|
||||
item["properties"]["operation_id"]["const"]: item
|
||||
for item in schema["oneOf"]
|
||||
}
|
||||
|
||||
assert {
|
||||
"music.recognize",
|
||||
"music.explore",
|
||||
"music.album.get",
|
||||
"music.album.related",
|
||||
"music.artist.get",
|
||||
"music.artist.albums",
|
||||
"music.artist.related",
|
||||
"music.cache.get",
|
||||
"music.cache.delete",
|
||||
"music.cache.clear",
|
||||
}.issubset(branches)
|
||||
album_path = branches["music.album.get"]["properties"]["path_params"]
|
||||
assert album_path["required"] == ["album_id"]
|
||||
artist_albums = branches["music.artist.albums"]
|
||||
assert artist_albums["properties"]["path_params"]["required"] == ["artist_id"]
|
||||
album_type = artist_albums["properties"]["query"]["properties"]["album_type"]
|
||||
assert "single" in str(album_type)
|
||||
assert branches["music.cache.delete"]["properties"]["path_params"]["required"] == [
|
||||
"cache_key"
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> None:
|
||||
"""外部 MCP tools/list 必须返回全部 operation 的精确 oneOf,而不是通用字典。"""
|
||||
manager = MoviePilotToolsManager(session_id="session", user_id="api_user")
|
||||
manager.tools = [MoviePilotApiTool(session_id="session", user_id="api_user")]
|
||||
|
||||
definition = manager.list_tools()[0]
|
||||
operation_ids = {
|
||||
branch["properties"]["operation_id"]["const"]
|
||||
for branch in definition.input_schema["oneOf"]
|
||||
}
|
||||
|
||||
assert definition.name == "moviepilot_api"
|
||||
assert operation_ids == set(API_OPERATION_ROUTES)
|
||||
|
||||
|
||||
def test_filter_read_parameters_are_query_fields_not_get_request_bodies() -> None:
|
||||
"""规则读取筛选列表必须作为 query 参数公开,避免 Agent 构造无语义 GET body。"""
|
||||
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
|
||||
branches = {
|
||||
item["properties"]["operation_id"]["const"]: item
|
||||
for item in schema["oneOf"]
|
||||
}
|
||||
|
||||
assert "body" not in branches["filter.builtin"]["properties"]
|
||||
assert "rule_ids" in branches["filter.builtin"]["properties"]["query"]["properties"]
|
||||
assert "body" not in branches["filter.custom"]["properties"]
|
||||
assert "rule_ids" in branches["filter.custom"]["properties"]["query"]["properties"]
|
||||
assert "body" not in branches["filter.groups"]["properties"]
|
||||
assert "group_names" in branches["filter.groups"]["properties"]["query"]["properties"]
|
||||
|
||||
|
||||
def test_plugin_operations_expose_discovery_before_precise_writes() -> None:
|
||||
"""插件配置和来源操作必须给 Agent 可先发现再精确写入的完整合同。"""
|
||||
schema = MoviePilotApiTool(session_id="session", user_id="api_user").get_mcp_input_schema()
|
||||
branches = {
|
||||
item["properties"]["operation_id"]["const"]: item
|
||||
for item in schema["oneOf"]
|
||||
}
|
||||
|
||||
installed_query = branches["plugin.installed"]["properties"]["query"]
|
||||
assert installed_query["properties"]["state"]["const"] == "installed"
|
||||
assert "query" in installed_query["properties"]
|
||||
assert installed_query["properties"]["max_results"]["maximum"] == 200
|
||||
|
||||
config_get_path = API_OPERATION_ROUTES["plugin.config.get"].path
|
||||
assert config_get_path == "/api/v1/plugin/form/{plugin_id}"
|
||||
config_body = branches["plugin.config.update"]["properties"]["body"]
|
||||
assert config_body["minProperties"] == 1
|
||||
assert "First call plugin.config.get" in config_body["description"]
|
||||
|
||||
assert branches["plugin.source.install"]["properties"]["body"]["$ref"].endswith(
|
||||
"/PluginSourceInstallRequest"
|
||||
)
|
||||
assert branches["plugin.source.change"]["properties"]["body"]["$ref"].endswith(
|
||||
"/PluginSourceChangeRequest"
|
||||
)
|
||||
|
||||
|
||||
def test_dev_upgrade_mcp_schema_requires_exact_scalar_body() -> None:
|
||||
"""Dev 更新必须暴露精确字符串 body,不能回退为任意 JSON。"""
|
||||
schema = MoviePilotApiTool(
|
||||
session_id="session",
|
||||
user_id="api_user",
|
||||
).get_mcp_input_schema()
|
||||
branch = next(
|
||||
item
|
||||
for item in schema["oneOf"]
|
||||
if item["properties"]["operation_id"].get("const") == "system.upgrade.dev"
|
||||
)
|
||||
|
||||
assert branch["properties"]["body"]["const"] == "dev"
|
||||
assert branch["properties"]["body"]["type"] == "string"
|
||||
assert "body" in branch["required"]
|
||||
|
||||
|
||||
def test_gateway_forwards_exact_scalar_body() -> None:
|
||||
"""网关必须原样传递少数固定 operation 声明的 JSON 标量请求体。"""
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = json.dumps({"success": True})
|
||||
gateway = MoviePilotApiTool(
|
||||
session_id="session",
|
||||
user_id="api_user",
|
||||
executor=executor,
|
||||
)
|
||||
gateway.set_agent_context({"is_admin": True})
|
||||
|
||||
result = asyncio.run(
|
||||
gateway.run(
|
||||
operation_id="system.upgrade.dev",
|
||||
body="dev",
|
||||
)
|
||||
)
|
||||
|
||||
assert json.loads(result)["success"] is True
|
||||
executor.execute.assert_awaited_once_with(
|
||||
"system.upgrade.dev",
|
||||
path_params=None,
|
||||
query=None,
|
||||
body="dev",
|
||||
)
|
||||
|
||||
|
||||
def test_system_settings_mcp_schema_explains_both_setting_sources() -> None:
|
||||
"""外部 MCP Client 应直接看到系统设置发现与精确更新参数语义。"""
|
||||
schema = MoviePilotApiTool(
|
||||
session_id="session",
|
||||
user_id="api_user",
|
||||
).get_mcp_input_schema()
|
||||
branches = {
|
||||
branch["properties"]["operation_id"]["const"]: branch
|
||||
for branch in schema["oneOf"]
|
||||
}
|
||||
query = branches["config.system.get"]["properties"]["query"]["properties"]
|
||||
update_ref = branches["config.system.update"]["properties"]["body"]["$ref"]
|
||||
update = schema["$defs"][update_ref.rsplit("/", 1)[-1]]["properties"]
|
||||
|
||||
assert "Settings field names" in query["setting_key"]["description"]
|
||||
assert "systemconfig" in query["group"]["description"]
|
||||
assert "confirmation-protected" in query["show_secrets"]["description"]
|
||||
assert "config.system.get" in update["setting_key"]["description"]
|
||||
assert "upsert_list_item" in update["operation"]["description"]
|
||||
assert "NotificationSwitchs" in update["match_field"]["description"]
|
||||
|
||||
|
||||
def test_api_mcp_schema_gives_every_field_concrete_english_guidance() -> None:
|
||||
"""MCP 合同字段不得回退为抽象占位说明或中英文混排。"""
|
||||
schema = MoviePilotApiTool(
|
||||
session_id="session",
|
||||
user_id="api_user",
|
||||
).get_mcp_input_schema()
|
||||
descriptions = []
|
||||
|
||||
def collect(node) -> None:
|
||||
"""递归收集对象字段说明并断言没有遗漏。"""
|
||||
if isinstance(node, dict):
|
||||
properties = node.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
for field_name, field_schema in properties.items():
|
||||
assert isinstance(field_schema, dict), field_name
|
||||
description = field_schema.get("description")
|
||||
assert isinstance(description, str) and description.strip(), field_name
|
||||
descriptions.append(description)
|
||||
for value in node.values():
|
||||
collect(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
collect(value)
|
||||
|
||||
collect(schema)
|
||||
rendered = "\n".join(descriptions)
|
||||
assert "按接口模型语义传值" not in rendered
|
||||
assert "declared by the selected operation" not in rendered
|
||||
assert "use the matching oneOf branch for its exact type" not in rendered
|
||||
assert not re.search(r"[\u3400-\u9fff]", rendered)
|
||||
|
||||
|
||||
def test_policy_classifies_api_operation_by_operation_id() -> None:
|
||||
@@ -94,6 +317,27 @@ def test_gateway_rejects_unknown_operation() -> None:
|
||||
assert "unknown_operation" in result
|
||||
|
||||
|
||||
def test_gateway_rejects_admin_operation_for_non_admin_before_http() -> None:
|
||||
"""管理员 operation 必须在网关层拒绝普通用户,不能只依赖最终端点。"""
|
||||
executor = AsyncMock()
|
||||
gateway = MoviePilotApiTool(
|
||||
session_id="session",
|
||||
user_id="1",
|
||||
executor=executor,
|
||||
)
|
||||
gateway.set_agent_context({"is_admin": False})
|
||||
|
||||
result = asyncio.run(
|
||||
gateway.run(
|
||||
operation_id="config.system.get",
|
||||
query={"group": "settings"},
|
||||
)
|
||||
)
|
||||
|
||||
assert json.loads(result)["error"] == "permission_denied"
|
||||
executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
def test_gateway_resolves_http_manager_to_persisted_superuser() -> None:
|
||||
"""MCP/HTTP 管理入口应绑定真实管理员,而不是伪造 api_user 身份。"""
|
||||
gateway = MoviePilotApiTool(session_id="session", user_id="api_user")
|
||||
@@ -113,6 +357,31 @@ def test_gateway_resolves_http_manager_to_persisted_superuser() -> None:
|
||||
assert identity == ("7", "admin", True)
|
||||
|
||||
|
||||
def test_gateway_maps_verified_channel_admin_only_for_admin_operation() -> None:
|
||||
"""通知渠道管理员执行管理员 operation 时应延续旧工具的管理员语义。"""
|
||||
gateway = MoviePilotApiTool(session_id="session", user_id="telegram-user")
|
||||
gateway.set_message_attr(
|
||||
channel="telegram",
|
||||
source="main-bot",
|
||||
username="channel-user",
|
||||
)
|
||||
gateway.set_agent_context({"is_admin": True})
|
||||
|
||||
with patch(
|
||||
"app.application.security.auth.build_superuser_token_payload",
|
||||
return_value=SimpleNamespace(
|
||||
sub=7,
|
||||
username="admin",
|
||||
super_user=True,
|
||||
),
|
||||
):
|
||||
identity = asyncio.run(
|
||||
gateway._resolve_api_identity(require_system_admin=True)
|
||||
)
|
||||
|
||||
assert identity == ("7", "admin", True)
|
||||
|
||||
|
||||
def test_factory_uses_api_catalog_by_default(monkeypatch) -> None:
|
||||
"""统一工具工厂默认只暴露原生能力和 API 网关。"""
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Focused tests for safe user-level Agent API projections."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.api.endpoints import site as site_endpoint
|
||||
from app.api.endpoints import storage as storage_endpoint
|
||||
from app.api.endpoints import workflow as workflow_endpoint
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
|
||||
class _SiteQuery:
|
||||
"""Return a stable mixed site list for projection tests."""
|
||||
|
||||
async def list_ordered(self):
|
||||
"""Return one active and one inactive site with authentication fields."""
|
||||
common = {
|
||||
"domain": "example.invalid",
|
||||
"url": "https://example.invalid/",
|
||||
"pri": 0,
|
||||
"downloader": "main",
|
||||
"ua": "agent-test",
|
||||
"proxy": False,
|
||||
"filter": None,
|
||||
"render": False,
|
||||
"public": False,
|
||||
"note": None,
|
||||
"limit_interval": None,
|
||||
"limit_count": None,
|
||||
"limit_seconds": None,
|
||||
"timeout": 30,
|
||||
"rss": "https://example.invalid/rss",
|
||||
"cookie": "secret-cookie",
|
||||
"apikey": "secret-key",
|
||||
"token": "secret-token",
|
||||
}
|
||||
return [
|
||||
SimpleNamespace(id=1, name="Active Site", is_active=True, **common),
|
||||
SimpleNamespace(id=2, name="Inactive Site", is_active=False, **common),
|
||||
]
|
||||
|
||||
|
||||
class _WorkflowQuery:
|
||||
"""Return workflows with private action context that the Agent projection must omit."""
|
||||
|
||||
async def list(self):
|
||||
"""Return one manual running workflow and one timer workflow."""
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=1,
|
||||
name="Manual Workflow",
|
||||
description="visible",
|
||||
trigger_type="manual",
|
||||
state="R",
|
||||
run_count=2,
|
||||
timer=None,
|
||||
event_type=None,
|
||||
add_time="2026-08-31",
|
||||
last_time="2026-08-31",
|
||||
current_action=1,
|
||||
actions=[{"private": "context"}],
|
||||
result={"private": "result"},
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=2,
|
||||
name="Timer Workflow",
|
||||
description=None,
|
||||
trigger_type=None,
|
||||
state="W",
|
||||
run_count=0,
|
||||
timer="0 0 * * *",
|
||||
event_type=None,
|
||||
add_time="2026-08-31",
|
||||
last_time=None,
|
||||
current_action=None,
|
||||
actions=[],
|
||||
result=None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_site_agent_projection_filters_and_hides_secrets_for_normal_users() -> None:
|
||||
"""Normal users may list sites but must not receive authentication material."""
|
||||
result = asyncio.run(
|
||||
site_endpoint.read_agent_sites(
|
||||
status="active",
|
||||
name="active",
|
||||
query=_SiteQuery(),
|
||||
current_user=SimpleNamespace(is_superuser=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert [item["name"] for item in result] == ["Active Site"]
|
||||
assert all(key not in result[0] for key in ("rss", "cookie", "apikey", "token"))
|
||||
|
||||
|
||||
def test_site_agent_projection_returns_auth_fields_only_to_superusers() -> None:
|
||||
"""A verified superuser keeps the old administrator site-query fidelity."""
|
||||
result = asyncio.run(
|
||||
site_endpoint.read_agent_sites(
|
||||
status="inactive",
|
||||
query=_SiteQuery(),
|
||||
current_user=SimpleNamespace(is_superuser=True),
|
||||
)
|
||||
)
|
||||
|
||||
assert result[0]["name"] == "Inactive Site"
|
||||
assert result[0]["cookie"] == "secret-cookie"
|
||||
assert result[0]["apikey"] == "secret-key"
|
||||
|
||||
|
||||
def test_workflow_agent_projection_filters_without_returning_action_context() -> None:
|
||||
"""User-level workflow discovery must expose state without private execution payloads."""
|
||||
result = asyncio.run(
|
||||
workflow_endpoint.list_agent_workflows(
|
||||
state="R",
|
||||
name="manual",
|
||||
trigger_type="manual",
|
||||
query=_WorkflowQuery(),
|
||||
_=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Manual Workflow",
|
||||
"description": "visible",
|
||||
"trigger_type": "manual",
|
||||
"state": "R",
|
||||
"run_count": 2,
|
||||
"timer": None,
|
||||
"event_type": None,
|
||||
"add_time": "2026-08-31",
|
||||
"last_time": "2026-08-31",
|
||||
"current_action": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_storage_agent_list_reuses_bounded_filter_and_sort(monkeypatch) -> None:
|
||||
"""User-level storage reads must preserve keyword filtering and stable sorting."""
|
||||
class _StorageChain:
|
||||
"""Return a fixed directory listing without touching a real storage provider."""
|
||||
|
||||
def list_files(self, _fileitem):
|
||||
"""Return two entries in reverse natural-name order."""
|
||||
return [
|
||||
FileItem(path="/b", name="Episode 10", modify_time=1),
|
||||
FileItem(path="/a", name="Episode 2", modify_time=2),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(storage_endpoint, "StorageChain", _StorageChain)
|
||||
|
||||
result = storage_endpoint.list_agent_files(
|
||||
fileitem=FileItem(path="/"),
|
||||
sort="name",
|
||||
keyword="Episode*",
|
||||
_=object(),
|
||||
)
|
||||
|
||||
assert [item.name for item in result] == ["Episode 2", "Episode 10"]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Agent API surface inventory and generated contract drift tests."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
AUDIT_JSON = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.json"
|
||||
AUDIT_MARKDOWN = PROJECT_ROOT / "docs/architecture/agent-api-surface-audit.md"
|
||||
API_SKILL = PROJECT_ROOT / "skills/moviepilot-api/SKILL.md"
|
||||
|
||||
|
||||
def _load_generator() -> dict:
|
||||
"""Load the audit generator without invoking its file-writing entrypoint."""
|
||||
return runpy.run_path(str(PROJECT_ROOT / "scripts/generate_agent_api_surface_audit.py"))
|
||||
|
||||
|
||||
def test_agent_api_surface_audit_matches_live_openapi_and_registry() -> None:
|
||||
"""The checked-in complete inventory must match live OpenAPI and the gateway registry."""
|
||||
generator = _load_generator()
|
||||
live = generator["generate_audit"]()
|
||||
checked_in = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
|
||||
|
||||
assert checked_in == live
|
||||
assert AUDIT_MARKDOWN.read_text(encoding="utf-8") == generator["render_markdown"](live)
|
||||
assert live["openapi_operation_count"] == len(live["operations"])
|
||||
assert live["gateway_operation_count"] == len(API_OPERATION_ROUTES)
|
||||
assert sum(live["disposition_counts"].values()) == live["openapi_operation_count"]
|
||||
assert {item["disposition"] for item in live["operations"]} == {
|
||||
"alternate-auth-duplicate",
|
||||
"consolidated",
|
||||
"gateway",
|
||||
"provider-skill",
|
||||
"stream_or_binary",
|
||||
"transport_or_identity",
|
||||
"ui_presentation",
|
||||
}
|
||||
|
||||
|
||||
def test_every_gateway_operation_has_one_exact_english_skill_and_mcp_contract() -> None:
|
||||
"""Every approved operation must be discoverable with matching exact English contracts."""
|
||||
skill = API_SKILL.read_text(encoding="utf-8")
|
||||
schema = MoviePilotApiTool(session_id="audit", user_id="1").get_mcp_input_schema()
|
||||
branches = {
|
||||
branch["properties"]["operation_id"]["const"]: branch
|
||||
for branch in schema["oneOf"]
|
||||
}
|
||||
|
||||
assert set(branches) == set(API_OPERATION_ROUTES)
|
||||
for operation_id, route in API_OPERATION_ROUTES.items():
|
||||
assert skill.count(f"### `{operation_id}`") == 1
|
||||
branch = branches[operation_id]
|
||||
assert branch["description"].strip()
|
||||
assert not re.search(r"[\u3400-\u9fff]", branch["description"]), operation_id
|
||||
assert route.method in skill.split(f"### `{operation_id}`", 1)[1].split("\n### `", 1)[0]
|
||||
assert route.path in skill.split(f"### `{operation_id}`", 1)[1].split("\n### `", 1)[0]
|
||||
|
||||
|
||||
def test_every_gateway_path_placeholder_is_required_by_its_mcp_branch() -> None:
|
||||
"""固定路由的每个路径占位符都必须在工具 schema 中以同名必填字段暴露。"""
|
||||
schema = MoviePilotApiTool(session_id="audit", user_id="1").get_mcp_input_schema()
|
||||
branches = {
|
||||
branch["properties"]["operation_id"]["const"]: branch
|
||||
for branch in schema["oneOf"]
|
||||
}
|
||||
|
||||
for operation_id, route in API_OPERATION_ROUTES.items():
|
||||
expected = set(re.findall(r"{([^}]+)}", route.path))
|
||||
path_schema = branches[operation_id].get("properties", {}).get("path_params", {})
|
||||
assert set(path_schema.get("properties", {})) == expected, operation_id
|
||||
assert set(path_schema.get("required", [])) == expected, operation_id
|
||||
|
||||
|
||||
def test_every_non_gateway_openapi_route_has_an_explicit_owner_and_reason() -> None:
|
||||
"""Unexposed REST routes must remain visible and deliberately owned, never silently omitted."""
|
||||
audit = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
|
||||
keys = set()
|
||||
for item in audit["operations"]:
|
||||
key = (item["method"], item["path"])
|
||||
assert key not in keys
|
||||
keys.add(key)
|
||||
assert item["owner"].strip()
|
||||
assert item["reason"].strip()
|
||||
if item["disposition"] != "gateway":
|
||||
assert item["disposition"] in {
|
||||
"alternate-auth-duplicate",
|
||||
"consolidated",
|
||||
"provider-skill",
|
||||
"stream_or_binary",
|
||||
"transport_or_identity",
|
||||
"ui_presentation",
|
||||
}
|
||||
|
||||
dynamic = audit["dynamic_gateway_routes"]
|
||||
assert dynamic == [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/{source}/person/credits/{person_id}",
|
||||
"operation_ids": ["media.person.credits"],
|
||||
"reason": (
|
||||
"The executor validates and expands this bounded source placeholder to one of "
|
||||
"tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
|
||||
),
|
||||
}
|
||||
]
|
||||
@@ -188,9 +188,24 @@ async def test_save_system_config_and_settings_service(monkeypatch):
|
||||
assert filter_config.async_set.await_count == 1
|
||||
secret = service.query(setting_key=SystemConfigKey.Downloaders.value, include_values=True)
|
||||
assert secret["settings"][0]["value"][0]["token"] == "***"
|
||||
definition = secret["settings"][0]["definition"]
|
||||
assert definition == {
|
||||
"declared_type": "list[object]",
|
||||
"value_shape": "list",
|
||||
"nullable": False,
|
||||
"sensitive": True,
|
||||
"update_operations": ["replace", "upsert_list_item", "remove_list_item"],
|
||||
"default_match_field": "name",
|
||||
"persistence": "database:systemconfig",
|
||||
}
|
||||
shown = service.query(setting_key=SystemConfigKey.Downloaders.value, include_values=True, show_secrets=True)
|
||||
assert shown["settings"][0]["value"][0]["token"] == "secret"
|
||||
assert service.query(group="ai_agent")["include_values"] is False
|
||||
runtime_definition = service.query(setting_key="LLM_MODEL")["settings"][0]["definition"]
|
||||
assert runtime_definition["declared_type"]
|
||||
assert runtime_definition["value_shape"] == "str"
|
||||
assert runtime_definition["update_operations"] == ["replace"]
|
||||
assert runtime_definition["persistence"] == "app.env"
|
||||
spec = settings_module.resolve_setting_spec(SystemConfigKey.Downloaders.value)
|
||||
assert spec
|
||||
assert service._prepare_next_value(spec, {"name": "old", "x": 1}, {"name": "old", "y": 2}, "merge_dict", ["x"], None, None) == {"name": "old", "y": 2}
|
||||
|
||||
@@ -73,6 +73,8 @@ def test_agent_prompt_delegates_explicit_secret_reads_to_host_confirmation() ->
|
||||
|
||||
assert "config.system.get" in prompt
|
||||
assert "show_secrets" in prompt
|
||||
assert "query.show_secrets=true" in prompt
|
||||
assert "body.show_secrets=true" not in prompt
|
||||
assert "do not refuse" in prompt
|
||||
assert "host verifies administrator authority" in prompt
|
||||
assert "Never expose or repeat the secret" in prompt
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -15,6 +16,8 @@ from app.agent.middleware.skills import (
|
||||
)
|
||||
from app.agent.tools.tags import ToolTag
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
@@ -105,6 +108,21 @@ async def test_skill_tool_caps_large_result_before_model_context(tmp_path):
|
||||
assert "Skill 内容已截断" in payload["content"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None:
|
||||
"""内置 API Skill 的完整 operation 合同必须在运行时上限内且不截断。"""
|
||||
middleware = SkillsMiddleware(sources=[str(PROJECT_ROOT / "skills")])
|
||||
|
||||
result = await middleware.tools[0].ainvoke({"name": "moviepilot-api"})
|
||||
payload = json.loads(result)
|
||||
|
||||
assert len(result) <= MAX_SKILL_RESULT_CHARS
|
||||
assert payload["success"] is True
|
||||
assert payload["truncated"] is False
|
||||
assert len(payload["skill"]["allowed_api_operations"]) == 203
|
||||
assert "### `workflow.update`" in payload["content"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path):
|
||||
"""skill 工具找不到技能时应返回结构化失败信息。"""
|
||||
|
||||
@@ -1,9 +1,94 @@
|
||||
import re
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS_ROOT = PROJECT_ROOT / "skills"
|
||||
CORE_PROMPT_PATH = PROJECT_ROOT / "app/agent/prompt/System Core Prompt.txt"
|
||||
|
||||
RETIRED_TOOL_COVERAGE: dict[str, tuple[str, ...]] = {
|
||||
"add_custom_filter_rule": ("api:filter.custom.add",),
|
||||
"add_download_tasks": ("api:download.add",),
|
||||
"add_rule_group": ("api:filter.group.add",),
|
||||
"add_subscribe": ("api:subscription.add",),
|
||||
"create_agent_task": ("native:agent_task",),
|
||||
"delete_agent_task": ("native:agent_task",),
|
||||
"delete_custom_filter_rule": ("api:filter.custom.delete",),
|
||||
"delete_download_history": ("api:download.history.delete",),
|
||||
"delete_download_tasks": ("downloader:tasks.delete",),
|
||||
"delete_rule_group": ("api:filter.group.delete",),
|
||||
"delete_subscribe": ("api:subscription.delete",),
|
||||
"delete_transfer_history": ("api:transfer.history.delete",),
|
||||
"get_recommendations": ("api:recommendation.list",),
|
||||
"get_search_results": ("api:search.results",),
|
||||
"install_plugin": ("api:plugin.install", "api:plugin.source.install"),
|
||||
"list_directory": ("api:storage.list",),
|
||||
"list_slash_commands": ("api:slash.list",),
|
||||
"query_agent_tasks": ("native:agent_task",),
|
||||
"query_builtin_filter_rules": ("api:filter.builtin",),
|
||||
"query_custom_filter_rules": ("api:filter.custom",),
|
||||
"query_custom_identifiers": ("api:config.identifiers.get",),
|
||||
"query_directory_settings": ("api:storage.settings",),
|
||||
"query_download_tasks": ("downloader:tasks.list",),
|
||||
"query_downloaders": ("api:download.clients", "downloader:instances.list"),
|
||||
"query_episode_schedule": ("api:media.episode_schedule",),
|
||||
"query_installed_plugins": ("api:plugin.installed",),
|
||||
"query_library_exists": ("api:library.exists",),
|
||||
"query_library_latest": ("api:library.latest", "mediaserver:activity.latest"),
|
||||
"query_market_plugins": ("api:plugin.market",),
|
||||
"query_media_detail": ("api:media.detail",),
|
||||
"query_personas": ("native:persona",),
|
||||
"query_plugin_capabilities": ("api:plugin.capabilities",),
|
||||
"query_plugin_config": ("api:plugin.config.get",),
|
||||
"query_plugin_data": ("api:plugin.data",),
|
||||
"query_popular_subscribes": ("api:subscription.popular",),
|
||||
"query_rule_groups": ("api:filter.groups",),
|
||||
"query_schedulers": ("api:scheduler.list",),
|
||||
"query_site_userdata": ("api:site.userdata",),
|
||||
"query_sites": ("api:site.list",),
|
||||
"query_subscribe_history": ("api:subscription.history",),
|
||||
"query_subscribe_shares": ("api:subscription.shares",),
|
||||
"query_subscribes": ("api:subscription.list",),
|
||||
"query_system_settings": ("api:config.system.get",),
|
||||
"query_transfer_history": ("api:transfer.history",),
|
||||
"query_workflows": ("api:workflow.list",),
|
||||
"recognize_media": ("api:media.recognize",),
|
||||
"reload_plugin": ("api:plugin.reload",),
|
||||
"run_agent_task": ("native:agent_task",),
|
||||
"run_scheduler": ("api:scheduler.run",),
|
||||
"run_slash_command": ("api:slash.run",),
|
||||
"run_workflow": ("api:workflow.run",),
|
||||
"scrape_metadata": ("api:media.scrape",),
|
||||
"search_media": ("api:media.search",),
|
||||
"search_person": ("api:media.person.search",),
|
||||
"search_person_credits": ("api:media.person.credits",),
|
||||
"search_subscribe": ("api:subscription.search",),
|
||||
"search_torrents": ("api:search.torrents",),
|
||||
"switch_persona": ("native:persona",),
|
||||
"test_site": ("api:site.test",),
|
||||
"transfer_file": ("api:transfer.file",),
|
||||
"uninstall_plugin": ("api:plugin.uninstall",),
|
||||
"update_agent_task": ("native:agent_task",),
|
||||
"update_custom_filter_rule": ("api:filter.custom.update",),
|
||||
"update_custom_identifiers": ("api:config.identifiers.update",),
|
||||
"update_download_tasks": (
|
||||
"downloader:tasks.start",
|
||||
"downloader:tasks.stop",
|
||||
"downloader:tasks.tags.set",
|
||||
"downloader:tasks.properties.set",
|
||||
"downloader:tasks.trackers.update",
|
||||
"downloader:tasks.location.set",
|
||||
"downloader:tasks.category.set",
|
||||
),
|
||||
"update_persona_definition": ("native:persona",),
|
||||
"update_plugin_config": ("api:plugin.config.get", "api:plugin.config.update"),
|
||||
"update_rule_group": ("api:filter.group.update",),
|
||||
"update_site": ("api:site.update",),
|
||||
"update_site_cookie": ("api:site.cookie.update",),
|
||||
"update_subscribe": ("api:subscription.update",),
|
||||
"update_system_settings": ("api:config.system.get", "api:config.system.update"),
|
||||
}
|
||||
|
||||
|
||||
def _read_skill(skill_name: str) -> str:
|
||||
"""读取内置技能的 SKILL.md 内容。"""
|
||||
@@ -23,18 +108,18 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
expected_versions = {
|
||||
"browser-use": "2",
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "5",
|
||||
"database-operation": "6",
|
||||
"feedback-issue": "9",
|
||||
"moviepilot-api": "16",
|
||||
"moviepilot-update": "4",
|
||||
"moviepilot-api": "23",
|
||||
"moviepilot-update": "5",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
"generate-identifiers": "4",
|
||||
"create-moviepilot-plugin": "5",
|
||||
"create-moviepilot-skill": "3",
|
||||
"publish-moviepilot-plugin": "3",
|
||||
"downloader-operation": "2",
|
||||
"mediaserver-operation": "2",
|
||||
"downloader-operation": "3",
|
||||
"mediaserver-operation": "3",
|
||||
}
|
||||
|
||||
for skill_name, expected_version in expected_versions.items():
|
||||
@@ -46,6 +131,41 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
def test_retired_moviepilot_cli_skill_is_removed() -> None:
|
||||
"""正式 API 方案不得保留旧 MCP CLI Skill。"""
|
||||
assert not (SKILLS_ROOT / "moviepilot-cli" / "SKILL.md").exists()
|
||||
assert not (SKILLS_ROOT / "moviepilot-api" / "scripts" / "mp-api.py").exists()
|
||||
assert not (SKILLS_ROOT / "moviepilot-update" / "scripts" / "mp-update.py").exists()
|
||||
|
||||
|
||||
def test_every_retired_business_tool_has_a_live_precise_owner() -> None:
|
||||
"""全部 72 个退出业务工具必须由 API、provider Skill 或统一原生工具承接。"""
|
||||
from app.agent.policy.api import API_OPERATION_ROUTES
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
|
||||
downloader_actions = runpy.run_path(
|
||||
str(SKILLS_ROOT / "downloader-operation/scripts/mp-downloader.py")
|
||||
)["ACTIONS"]
|
||||
mediaserver_actions = runpy.run_path(
|
||||
str(SKILLS_ROOT / "mediaserver-operation/scripts/mp-mediaserver.py")
|
||||
)["ACTIONS"]
|
||||
native_tools = {
|
||||
MoviePilotToolFactory._tool_class_name(tool_class)
|
||||
for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES
|
||||
}
|
||||
|
||||
assert len(RETIRED_TOOL_COVERAGE) == 72
|
||||
for retired_name, owners in RETIRED_TOOL_COVERAGE.items():
|
||||
assert not (PROJECT_ROOT / f"app/agent/tools/impl/{retired_name}.py").exists()
|
||||
assert owners, retired_name
|
||||
for owner in owners:
|
||||
owner_type, owner_name = owner.split(":", 1)
|
||||
if owner_type == "api":
|
||||
assert owner_name in API_OPERATION_ROUTES, (retired_name, owner)
|
||||
elif owner_type == "downloader":
|
||||
assert owner_name in downloader_actions, (retired_name, owner)
|
||||
elif owner_type == "mediaserver":
|
||||
assert owner_name in mediaserver_actions, (retired_name, owner)
|
||||
else:
|
||||
assert owner_type == "native"
|
||||
assert owner_name in native_tools, (retired_name, owner)
|
||||
|
||||
|
||||
def test_api_and_database_skills_declare_final_boundaries() -> None:
|
||||
@@ -59,11 +179,65 @@ def test_api_and_database_skills_declare_final_boundaries() -> None:
|
||||
assert "retired tool name" in api_content
|
||||
assert "moviepilot tool" in api_content
|
||||
|
||||
update_content = _read_skill("moviepilot-update")
|
||||
assert "allowed-tools: moviepilot_api" in update_content
|
||||
assert "system.update.install" in update_content
|
||||
assert "mp-api.py" not in update_content
|
||||
assert "mp-update.py" not in update_content
|
||||
|
||||
assert "direct SQL boundary" in db_content
|
||||
assert "Use this skill as the final fallback" in db_content
|
||||
assert "INSERT" in db_content
|
||||
assert "UPDATE" in db_content
|
||||
assert "DELETE" in db_content
|
||||
assert "allowed-tools: execute_command" in db_content
|
||||
from app.db.base import Base
|
||||
from app.db.models import load_all_models
|
||||
|
||||
load_all_models()
|
||||
for table_name in Base.metadata.tables:
|
||||
heading = f"### `{table_name}`"
|
||||
assert heading in db_content
|
||||
section = db_content.split(heading, 1)[1].split("\n### `", 1)[0]
|
||||
assert "- Purpose:" in section
|
||||
assert "- Useful queries:" in section
|
||||
assert "- Write boundary:" in section
|
||||
assert "- Columns:" in section
|
||||
assert "### `alembic_version`" in db_content
|
||||
alembic_section = db_content.split("### `alembic_version`", 1)[1].split(
|
||||
"\n### `", 1
|
||||
)[0]
|
||||
assert "- Purpose:" in alembic_section
|
||||
assert "- Useful queries:" in alembic_section
|
||||
assert "Never edit it directly" in alembic_section
|
||||
|
||||
|
||||
def test_api_skill_uses_runtime_system_setting_discovery() -> None:
|
||||
"""系统设置 Skill 应指导动态发现定义,而不是复制不断变化的键清单。"""
|
||||
api_content = _read_skill("moviepilot-api")
|
||||
|
||||
assert "## System Settings Contract" in api_content
|
||||
assert "Do not enumerate setting keys in this Skill" in api_content
|
||||
assert '`query={"group":"settings","keyword":"LLM"}`' in api_content
|
||||
assert "`declared_type`" in api_content
|
||||
assert "`update_operations`" in api_content
|
||||
assert "`persistence`" in api_content
|
||||
assert "### Settings variables" not in api_content
|
||||
assert "### SystemConfig keys" not in api_content
|
||||
|
||||
|
||||
def test_refactored_agent_skills_use_english_guidance() -> None:
|
||||
"""四个重构 Skill 的模型指导文本必须统一为英文。"""
|
||||
for skill_name in (
|
||||
"moviepilot-api",
|
||||
"downloader-operation",
|
||||
"mediaserver-operation",
|
||||
"database-operation",
|
||||
"moviepilot-update",
|
||||
):
|
||||
content = _read_skill(skill_name)
|
||||
assert not re.search(r"[\u3400-\u9fff]", content), skill_name
|
||||
assert "按接口模型语义传值" not in content
|
||||
|
||||
|
||||
def test_agent_core_prompt_does_not_block_plugin_source_edits() -> None:
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.service_operation import (
|
||||
from app.agent.tools.impl.service import (
|
||||
DatabaseOperationTool,
|
||||
DownloaderOperationTool,
|
||||
MediaServerOperationTool,
|
||||
)
|
||||
@@ -32,7 +36,7 @@ def test_downloader_mcp_schema_exposes_every_action_argument_and_rule() -> None:
|
||||
assert arguments["properties"]["task_ids"] == {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "多个任务的 provider 原生 hash 或 ID;与 task_id 二选一。",
|
||||
"description": "Multiple provider-native task hashes or IDs; mutually exclusive with task_id.",
|
||||
"minItems": 1,
|
||||
}
|
||||
assert arguments["properties"]["position"]["enum"] == ["top", "up", "down", "bottom"]
|
||||
@@ -68,6 +72,30 @@ def test_mediaserver_mcp_schema_exposes_nested_refresh_item_fields() -> None:
|
||||
assert item_schema["additionalProperties"] is False
|
||||
|
||||
|
||||
def test_database_mcp_schema_exposes_all_actions_and_sql_sources() -> None:
|
||||
"""数据库 MCP 工具应直接暴露四个 action 及 SQL/file 约束。"""
|
||||
tool = DatabaseOperationTool(session_id="session", user_id="api_user")
|
||||
schema = tool.get_mcp_input_schema()
|
||||
|
||||
assert schema["properties"]["action"]["enum"] == [
|
||||
"query",
|
||||
"schema",
|
||||
"tables",
|
||||
"write",
|
||||
]
|
||||
query_arguments = _branch(schema, "query")["properties"]["arguments"]
|
||||
assert set(query_arguments["properties"]) == {"sql", "file", "limit", "write"}
|
||||
assert query_arguments["properties"]["limit"]["default"] == 100
|
||||
assert query_arguments["properties"]["limit"]["minimum"] == 1
|
||||
assert query_arguments["oneOf"] == [
|
||||
{"required": ["sql"], "not": {"required": ["file"]}},
|
||||
{"required": ["file"], "not": {"required": ["sql"]}},
|
||||
]
|
||||
assert _branch(schema, "schema")["properties"]["arguments"]["required"] == [
|
||||
"table_name"
|
||||
]
|
||||
|
||||
|
||||
def test_direct_manager_preserves_service_operation_mcp_schema() -> None:
|
||||
"""工具管理器不得把服务操作的 oneOf 和嵌套 schema 压平成普通对象。"""
|
||||
manager = MoviePilotToolsManager(session_id="session", user_id="api_user")
|
||||
@@ -102,14 +130,50 @@ def test_factory_only_adds_service_operation_tools_for_external_manager() -> Non
|
||||
external_names = {tool.name for tool in external}
|
||||
assert "downloader_operation" not in internal_names
|
||||
assert "mediaserver_operation" not in internal_names
|
||||
assert {"downloader_operation", "mediaserver_operation"}.issubset(external_names)
|
||||
assert {
|
||||
"downloader_operation",
|
||||
"mediaserver_operation",
|
||||
"database_operation",
|
||||
}.issubset(external_names)
|
||||
|
||||
|
||||
def test_database_operation_tool_calls_fixed_script_once() -> None:
|
||||
"""数据库 MCP tools/call 应只执行一次固定脚本并返回 JSON envelope。"""
|
||||
tool = DatabaseOperationTool(session_id="session", user_id="api_user")
|
||||
with patch(
|
||||
"app.agent.tools.impl.service._run_database_script",
|
||||
return_value={"tables": ["agentchat"]},
|
||||
) as runner:
|
||||
result = asyncio.run(tool.run(action="tables", arguments={}))
|
||||
|
||||
assert json.loads(result) == {"tables": ["agentchat"]}
|
||||
runner.assert_called_once()
|
||||
assert runner.call_args.kwargs["arguments"] == {
|
||||
"action": "tables",
|
||||
"arguments": {},
|
||||
}
|
||||
|
||||
|
||||
def test_database_operation_rejects_invalid_nested_arguments_before_script() -> None:
|
||||
"""数据库工具应在连接数据库前一次性拒绝错误的嵌套参数。"""
|
||||
tool = DatabaseOperationTool(session_id="session", user_id="api_user")
|
||||
with patch("app.agent.tools.impl.service.subprocess.run") as runner:
|
||||
with pytest.raises(ValueError, match="sql 与 file"):
|
||||
asyncio.run(
|
||||
tool.run(
|
||||
action="query",
|
||||
arguments={"sql": "SELECT 1", "file": "query.sql"},
|
||||
)
|
||||
)
|
||||
|
||||
runner.assert_not_called()
|
||||
|
||||
|
||||
def test_service_operation_tool_calls_fixed_script_once() -> None:
|
||||
"""一次 MCP tools/call 应只执行一次固定脚本并返回其 JSON envelope。"""
|
||||
tool = DownloaderOperationTool(session_id="session", user_id="api_user")
|
||||
with patch(
|
||||
"app.agent.tools.impl.service_operation._run_service_script",
|
||||
"app.agent.tools.impl.service._run_service_script",
|
||||
return_value={"success": True, "action": "tasks.list", "data": {"items": []}},
|
||||
) as runner:
|
||||
result = asyncio.run(
|
||||
@@ -128,3 +192,42 @@ def test_service_operation_tool_calls_fixed_script_once() -> None:
|
||||
action="tasks.list",
|
||||
arguments={"limit": 20},
|
||||
)
|
||||
|
||||
|
||||
def test_service_operation_sync_script_does_not_block_event_loop() -> None:
|
||||
"""同步 provider 脚本必须在分域线程池运行,不能阻塞 Agent event loop。"""
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_runner(**_kwargs) -> dict:
|
||||
"""模拟尚未返回的同步第三方 SDK 调用。"""
|
||||
started.set()
|
||||
release.wait(timeout=1)
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
async def exercise() -> None:
|
||||
"""在同步脚本运行期间验证 loop 仍能调度其它协程。"""
|
||||
tool = DownloaderOperationTool(session_id="session", user_id="api_user")
|
||||
with patch(
|
||||
"app.agent.tools.impl.service._run_service_script",
|
||||
side_effect=slow_runner,
|
||||
):
|
||||
task = asyncio.create_task(
|
||||
tool.run(action="tasks.list", arguments={"limit": 1})
|
||||
)
|
||||
try:
|
||||
for _ in range(50):
|
||||
if started.is_set():
|
||||
break
|
||||
await asyncio.sleep(0.002)
|
||||
assert started.is_set()
|
||||
assert await asyncio.wait_for(
|
||||
asyncio.sleep(0, result="loop-responsive"),
|
||||
timeout=0.1,
|
||||
) == "loop-responsive"
|
||||
assert task.done() is False
|
||||
finally:
|
||||
release.set()
|
||||
assert json.loads(await task)["success"] is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
@@ -182,7 +182,7 @@ def test_downloader_instances_and_capabilities_do_not_expose_credentials(
|
||||
]
|
||||
rendered = str((instances, capabilities))
|
||||
assert "private.invalid" not in rendered
|
||||
assert "secret" not in rendered
|
||||
assert '"secret"' not in json.dumps((instances, capabilities), ensure_ascii=False)
|
||||
assert any(item["action"] == "tasks.peers" for item in capabilities["actions"])
|
||||
|
||||
|
||||
@@ -200,10 +200,10 @@ def test_downloader_capability_exposes_complete_action_arguments(
|
||||
"name": "position",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "目标队列位置。",
|
||||
"description": "Target queue position.",
|
||||
"enum": ["top", "up", "down", "bottom"],
|
||||
}
|
||||
assert action["argument_rules"] == ["task_id 与 task_ids 必须提供且只能选择一种。"]
|
||||
assert action["argument_rules"] == ["Provide exactly one of task_id and task_ids."]
|
||||
|
||||
|
||||
def test_downloader_argument_validation_reports_all_errors_before_config_load(
|
||||
@@ -394,7 +394,7 @@ def test_mediaserver_capabilities_are_provider_specific(
|
||||
assert "playback.sessions" not in action_names
|
||||
assert "metadata.refresh" not in action_names
|
||||
assert "private.invalid" not in str(result)
|
||||
assert "secret" not in str(result)
|
||||
assert '"secret"' not in json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_mediaserver_capability_exposes_nested_refresh_contract(
|
||||
@@ -411,8 +411,8 @@ def test_mediaserver_capability_exposes_nested_refresh_contract(
|
||||
"type": "object[]",
|
||||
"required": True,
|
||||
"description": (
|
||||
"刷新条目;每项支持 title:string、year:string|integer、type:电影|电视剧|音乐、"
|
||||
"category:string、target_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."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,9 +4,7 @@ from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MP_API_SCRIPT = PROJECT_ROOT / "skills" / "moviepilot-api" / "scripts" / "mp-api.py"
|
||||
MP_DB_SCRIPT = PROJECT_ROOT / "skills" / "database-operation" / "scripts" / "mp-db.py"
|
||||
|
||||
|
||||
@@ -19,36 +17,6 @@ def _load_script(path: Path, module_name: str) -> ModuleType:
|
||||
return module
|
||||
|
||||
|
||||
def test_mp_api_uses_settings_without_prompt_token(monkeypatch, tmp_path) -> None:
|
||||
"""API 脚本应直接读取 settings,而不是要求提示词提供 token。"""
|
||||
module = _load_script(MP_API_SCRIPT, "mp_api_script")
|
||||
runtime_dir = tmp_path / "temp"
|
||||
runtime_dir.mkdir()
|
||||
|
||||
class FakeSettings:
|
||||
"""提供 API 脚本本地配置所需字段。"""
|
||||
|
||||
TEMP_PATH = runtime_dir
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 3001
|
||||
API_TOKEN = "settings-token"
|
||||
|
||||
monkeypatch.setattr(module, "_ensure_project_import", lambda: None)
|
||||
monkeypatch.setattr(module, "read_config", lambda: ("http://file-host", "file-token"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.config.settings",
|
||||
FakeSettings,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.delenv("MP_HOST", raising=False)
|
||||
monkeypatch.delenv("MP_API_KEY", raising=False)
|
||||
|
||||
host, key = module.resolve_config()
|
||||
|
||||
assert host == "http://127.0.0.1:3001"
|
||||
assert key == "settings-token"
|
||||
|
||||
|
||||
def test_mp_db_rejects_write_statement_without_write_flag() -> None:
|
||||
"""数据库脚本默认必须拒绝写操作。"""
|
||||
module = _load_script(MP_DB_SCRIPT, "mp_db_script")
|
||||
|
||||
Reference in New Issue
Block a user