mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-21 00:23:39 +08:00
382 lines
15 KiB
Python
382 lines
15 KiB
Python
#!/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/refactor/agent-api-surface-audit.json"
|
|
MARKDOWN_OUTPUT = PROJECT_ROOT / "docs/refactor/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",
|
|
("GET", "/api/v1/media/category"): "media.classification.policy.get",
|
|
("GET", "/api/v1/media/category/config"): "media.classification.policy.get",
|
|
}
|
|
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/runtime/{plugin_id}/data/summary",
|
|
"/api/v1/plugin/sidebar_nav",
|
|
"/api/v1/rule/custom/reorder",
|
|
"/api/v1/rule/groups/reorder",
|
|
"/api/v1/storage/catalog",
|
|
"/api/v1/storage/options",
|
|
}
|
|
)
|
|
EXPLICIT_TRANSPORT_PATHS = frozenset(
|
|
{
|
|
"/api/v1/plugin/remotes",
|
|
"/api/v1/subscribe/seerr",
|
|
"/api/v1/system/ping",
|
|
}
|
|
)
|
|
SUBSCRIPTION_EXECUTION_UI_PREFIX = "/api/v1/subscribe/execution/"
|
|
MUSIC_LIBRARY_STATUS_UI_PATH = "/api/v1/music/library/status"
|
|
|
|
|
|
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]]:
|
|
"""把 OpenAPI 操作归入经过审查的 Agent 或直接管理端契约。"""
|
|
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 method == "POST" and path == "/api/v1/history/transfer/{history_id}/cleanup-resolved":
|
|
return (
|
|
"ui_presentation",
|
|
"host-ui",
|
|
"Manual confirmation of downloader cleanup is owned by the authenticated history management workflow; it is not an Agent action that verifies external cleanup.",
|
|
[],
|
|
)
|
|
if path == "/api/v1/history/transfer/{history_id}/discard-corrupt":
|
|
return (
|
|
"ui_presentation",
|
|
"host-ui",
|
|
"Discarding corrupt transfer state is owned by the authenticated management recovery workflow; it is not a stable Agent gateway operation.",
|
|
[],
|
|
)
|
|
if path.startswith(SUBSCRIPTION_EXECUTION_UI_PREFIX):
|
|
return (
|
|
"ui_presentation",
|
|
"host-ui",
|
|
"Background subscription execution status and cancellation are owned by the authenticated frontend workflow; they are not yet a stable Agent gateway contract.",
|
|
[],
|
|
)
|
|
if method == "POST" and path == MUSIC_LIBRARY_STATUS_UI_PATH:
|
|
return (
|
|
"ui_presentation",
|
|
"host-ui",
|
|
"Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone 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(f"generated {JSON_OUTPUT.relative_to(PROJECT_ROOT)} and {MARKDOWN_OUTPUT.relative_to(PROJECT_ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|