Simplify Athena stack and recovery
This commit is contained in:
@@ -1,38 +1,150 @@
|
||||
"""Radarr action-routed MCP tool with model-oriented routing guidance."""
|
||||
"""Small, model-oriented Radarr tools built on the upstream API client."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_utilities.mcp_utilities import dispatch, run_blocking
|
||||
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=(
|
||||
"Choose one Radarr operation. Common read choices include get_movie for the "
|
||||
"movie library and get_system_status/get_health for Radarr diagnostics. Use "
|
||||
"list_actions only when an unusual Radarr operation is genuinely required. "
|
||||
"Never guess a modifying action and never change Radarr without explicit user approval."
|
||||
"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 encoded as a string containing only parameters required by the "
|
||||
"selected Radarr action. Use \"{}\" for actions without parameters; never "
|
||||
"invent movie IDs, paths, profile IDs or monitoring settings."
|
||||
),
|
||||
description="JSON object string with only the selected action's required parameters.",
|
||||
),
|
||||
) -> Any:
|
||||
"""USE ONLY for movies managed by Radarr: inspect the movie library, wanted/queue/history state, releases, profiles, or Radarr health. DO NOT use for TV episodes (use Sonarr), public-web research, media playback, filesystem copying, or direct downloads. Prefer read actions; any mutation requires explicit user approval."""
|
||||
"""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()
|
||||
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user