"""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" )