151 lines
6.1 KiB
Python
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"
|
|
)
|