Files
AI-Profile-Router/platform/mcp/patches/mcp_radarr.py
T

151 lines
6.1 KiB
Python

"""Small, model-oriented Radarr tools built on the upstream API client."""
import json
from typing import Any
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
from fastmcp import FastMCP
from pydantic import Field
from arr_mcp.auth import get_radarr_client
BLOCKED_ACTIONS = {
"request", "get_", "get_api", "get_content_path", "get_path",
"get_login", "get_logout", "post_login", "post_system_restart",
"post_system_shutdown",
}
def _safe_actions(client: Any) -> list[str]:
"""Hide transport, authentication and service-power implementation methods."""
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS]
def _codec_aliases(value: str) -> set[str]:
aliases = {
"h264": {"h264", "x264", "avc"},
"x264": {"h264", "x264", "avc"},
"avc": {"h264", "x264", "avc"},
"h265": {"h265", "x265", "hevc"},
"x265": {"h265", "x265", "hevc"},
"hevc": {"h265", "x265", "hevc"},
}
requested: set[str] = set()
for item in value.split(","):
key = item.strip().casefold()
if key:
requested.update(aliases.get(key, {key}))
return requested
def _compact_inventory(
movies: list[dict[str, Any]], *, codecs: str = "", query: str = "",
offset: int = 0, limit: int = 200,
) -> dict[str, Any]:
wanted = _codec_aliases(codecs)
needle = query.strip().casefold()
rows: list[dict[str, Any]] = []
for movie in movies:
movie_file = movie.get("movieFile") or {}
if not movie.get("hasFile") or not movie_file:
continue
media = movie_file.get("mediaInfo") or {}
codec = str(media.get("videoCodec") or "unknown")
if wanted and codec.casefold() not in wanted:
continue
title = str(movie.get("title") or "")
if needle and needle not in title.casefold():
continue
quality = movie_file.get("quality") or {}
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
size = int(movie_file.get("size") or 0)
rows.append({
"radarrId": movie.get("id"),
"title": title,
"year": movie.get("year"),
"movieFileId": movie_file.get("id"),
"relativePath": movie_file.get("relativePath"),
"sizeBytes": size,
"sizeGiB": round(size / 1073741824, 2),
"quality": quality_name,
"resolution": media.get("resolution"),
"videoCodec": codec,
"videoBitDepth": media.get("videoBitDepth"),
"audioCodec": media.get("audioCodec"),
"audioLanguages": media.get("audioLanguages"),
"subtitles": media.get("subtitles"),
})
rows.sort(key=lambda row: (str(row["title"]).casefold(), row.get("year") or 0))
total = len(rows)
start = max(0, int(offset))
count = min(500, max(1, int(limit)))
selected = rows[start:start + count]
return {
"totalMatched": total,
"offset": start,
"returned": len(selected),
"hasMore": start + len(selected) < total,
"movies": selected,
}
def register_radarr_tools(mcp: FastMCP) -> None:
@mcp.tool(tags={"radarr"})
async def radarr_movie_codec_inventory(
video_codecs: str = Field(
default="",
description="Optional comma-separated filter, e.g. h264, x264, h265, x265 or hevc.",
),
query: str = Field(default="", description="Optional case-insensitive title fragment."),
offset: int = Field(default=0, ge=0),
limit: int = Field(default=200, ge=1, le=500),
) -> Any:
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
client = get_radarr_client()
response = await run_blocking(client.get_movie)
movies = response.get("result", response) if isinstance(response, dict) else response
if not isinstance(movies, list):
raise RuntimeError("Radarr get_movie returned an unexpected response")
return _compact_inventory(
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
)
@mcp.tool(tags={"radarr"})
async def radarr_action(
action: str = Field(
description=(
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for "
"library/file/codec questions. Use list_actions once for unusual operations. "
"Raw request, authentication and Radarr restart/shutdown methods are unavailable."
)
),
params_json: str = Field(
default="{}",
description="JSON object string with only the selected action's required parameters.",
),
) -> Any:
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request."""
client = get_radarr_client()
actions = _safe_actions(client)
if action in {"list_actions", "actions", "help", "capabilities"}:
return {"service": "arr-radarr", "actions": actions}
if action not in actions:
return {
"ok": False,
"error": "unknown or unavailable Radarr action",
"action": action,
"retry": False,
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
}
try:
parsed = json.loads(params_json)
except json.JSONDecodeError as exc:
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
if not isinstance(parsed, dict):
raise ValueError("params_json must encode a JSON object")
kwargs = {key: value for key, value in parsed.items() if value is not None}
return await run_blocking(
dispatch, client, action, kwargs, service="arr-radarr"
)