188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""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,
|
|
}
|