"""Small, explicit, read-only Radarr tools built on the upstream API client.""" import asyncio from typing import Any from fastmcp import FastMCP from pydantic import Field from arr_mcp.auth import get_radarr_client async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any: """Call one known upstream API method without exposing dynamic dispatch.""" return await asyncio.to_thread(getattr(client, action), **(kwargs or {})) def _plain(value: Any) -> Any: if hasattr(value, "model_dump") and callable(value.model_dump): return value.model_dump() if hasattr(value, "dict") and callable(value.dict): return value.dict() return value def _movies(value: Any) -> list[dict[str, Any]]: value = _plain(value) if isinstance(value, dict) and "result" in value: value = value["result"] return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] def _compact_movie(movie: dict[str, Any]) -> dict[str, Any]: return { key: movie[key] for key in ("id", "title", "originalTitle", "year", "status", "monitored", "hasFile", "path", "tmdbId") if movie.get(key) is not None } def _compact_release(item: dict[str, Any]) -> dict[str, Any]: quality = item.get("quality") or {} quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None result = { key: item[key] for key in ("guid", "title", "indexer", "indexerId", "size", "age", "seeders", "leechers", "protocol", "downloadAllowed", "releaseGroup") if item.get(key) is not None } if quality_name: result["quality"] = quality_name if isinstance(item.get("rejections"), list) and item["rejections"]: result["rejections"] = [str(reason)[:180] for reason in item["rejections"][:5]] return result 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_find_movie( query: str = Field(description="Movie title or title fragment."), limit: int = Field(default=10, ge=1, le=25), ) -> Any: """Find a movie already managed by Radarr. READ ONLY. Never starts a search or download.""" needle = query.strip().casefold() if len(needle) < 2: raise ValueError("query must contain at least two characters") movies = _movies(await _call(get_radarr_client(), "get_movie")) matches = [ _compact_movie(movie) for movie in movies if needle in " ".join(str(movie.get(key, "")) for key in ("title", "originalTitle", "sortTitle")).casefold() ] return {"query": query, "match_count": len(matches), "matches": matches[:limit], "truncated": len(matches) > limit} @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 = _plain(await _call(client, "get_movie")) movies = _movies(response) if not movies and response not in ([], {"result": []}): 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_search_releases( movie_id: int = Field(ge=1, description="Exact Radarr movie id returned by radarr_find_movie."), release_group: str = Field(default="", description="Optional release-group filter."), limit: int = Field(default=50, ge=1, le=100), ) -> Any: """Search Radarr's configured indexers for one movie. READ ONLY: never grabs or downloads a release.""" raw = _plain(await _call(get_radarr_client(), "get_release", {"movieId": movie_id})) if isinstance(raw, dict) and "result" in raw: raw = raw["result"] releases = [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else [] needle = release_group.strip().casefold() if needle: releases = [ item for item in releases if needle in (str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))).casefold() ] compact = [_compact_release(item) for item in releases[:limit]] return { "movie_id": movie_id, "release_group_filter": release_group or None, "total": len(releases), "returned": len(compact), "truncated": len(releases) > limit, "results": compact, "download_started": False, }