Skip to main content
Glama

arr-assistant-mcp

by omniwaifu
main.py19.8 kB
"""Natural Language Media Server MCP An MCP server that enables natural language querying and automatic addition of movies/TV shows to self-hosted Radarr and Sonarr instances. """ import asyncio import logging from typing import Dict, List, Optional, Any from dataclasses import dataclass from datetime import datetime import httpx from fastmcp import FastMCP from pydantic import BaseModel, Field # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Configuration models @dataclass class ServerConfig: radarr_url: str radarr_api_key: str sonarr_url: str sonarr_api_key: str tvdb_api_key: Optional[str] = None quality_profile_id: int = 1 radarr_root_folder: Optional[str] = None sonarr_root_folder: Optional[str] = None # Response models class MediaSearchResult(BaseModel): title: str year: Optional[int] = None overview: str tmdb_id: Optional[int] = None tvdb_id: Optional[int] = None poster_path: Optional[str] = None media_type: str # "movie" or "tv" class AddMediaResponse(BaseModel): success: bool message: str media_id: Optional[int] = None # Initialize FastMCP server mcp = FastMCP("Natural Language Media Server") # Global config (will be set via environment or config file) config: Optional[ServerConfig] = None class MediaServerAPI: """API client for Radarr and Sonarr""" def __init__(self, config: ServerConfig): self.config = config self.client = httpx.AsyncClient(timeout=30.0) def _select_best_match(self, query: str, results: List[MediaSearchResult]) -> Optional[MediaSearchResult]: """Just pick the first result - TMDb sorts by relevance anyway""" if results: return results[0] return None async def get_radarr_root_folders(self) -> List[Dict[str, Any]]: """Get available root folders from Radarr""" url = f"{self.config.radarr_url}/api/v3/rootfolder" headers = {"X-Api-Key": self.config.radarr_api_key} try: response = await self.client.get(url, headers=headers) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Failed to get Radarr root folders: {e}") return [] async def get_sonarr_root_folders(self) -> List[Dict[str, Any]]: """Get available root folders from Sonarr""" url = f"{self.config.sonarr_url}/api/v3/rootfolder" headers = {"X-Api-Key": self.config.sonarr_api_key} try: response = await self.client.get(url, headers=headers) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Failed to get Sonarr root folders: {e}") return [] async def search_radarr_movies(self, query: str) -> List[Dict[str, Any]]: """Search for movies using Radarr's built-in lookup (uses their TMDb access)""" url = f"{self.config.radarr_url}/api/v3/movie/lookup" headers = {"X-Api-Key": self.config.radarr_api_key} params = {"term": query} logger.info(f"Radarr lookup request: {url} with term='{query}'") try: response = await self.client.get(url, params=params, headers=headers) logger.info(f"Radarr response status: {response.status_code}") if response.status_code == 401: logger.error("Radarr authentication failed - check your API key") raise Exception("Radarr authentication failed - verify your API key is correct") elif response.status_code == 404: logger.error("Radarr lookup endpoint not found") raise Exception("Radarr lookup endpoint not found") response.raise_for_status() results = response.json() logger.info(f"Radarr returned {len(results)} results for query '{query}'") if results: logger.info(f"First result: {results[0].get('title')} ({results[0].get('year', 'No year')})") return results except Exception as e: logger.error(f"Radarr lookup error for query '{query}': {e}") raise e async def add_movie_to_radarr(self, tmdb_id: int, title: str, root_folder: Optional[str] = None) -> AddMediaResponse: """Add movie to Radarr""" url = f"{self.config.radarr_url}/api/v3/movie" headers = {"X-Api-Key": self.config.radarr_api_key} # Use provided title - Radarr will fetch additional details if not title: title = f"Movie (TMDb ID: {tmdb_id})" payload = { "title": title, "tmdbId": tmdb_id, "qualityProfileId": self.config.quality_profile_id, "monitored": True, "minimumAvailability": "announced", "addOptions": { "searchForMovie": True } } # Set root folder (parameter > config > auto-detect) if root_folder: payload["rootFolderPath"] = root_folder logger.info(f"Using specified root folder: {root_folder}") elif self.config.radarr_root_folder: payload["rootFolderPath"] = self.config.radarr_root_folder logger.info(f"Using configured root folder: {self.config.radarr_root_folder}") else: # Auto-detect first available root folder root_folders = await self.get_radarr_root_folders() if root_folders: payload["rootFolderPath"] = root_folders[0]["path"] logger.info(f"Using auto-detected Radarr root folder: {root_folders[0]['path']}") else: logger.warning("No Radarr root folders found - movie may fail to add") try: response = await self.client.post(url, json=payload, headers=headers) if response.status_code == 201: result = response.json() return AddMediaResponse( success=True, message=f"Successfully added '{title}' to Radarr", media_id=result.get("id") ) else: return AddMediaResponse( success=False, message=f"Failed to add movie: {response.text}" ) except Exception as e: logger.error(f"Radarr API error: {e}") return AddMediaResponse( success=False, message=f"Error communicating with Radarr: {str(e)}" ) async def search_sonarr_shows(self, query: str) -> List[Dict[str, Any]]: """Search for TV shows using Sonarr's built-in lookup""" url = f"{self.config.sonarr_url}/api/v3/series/lookup" headers = {"X-Api-Key": self.config.sonarr_api_key} params = {"term": query} logger.info(f"Sonarr lookup request: {url} with term='{query}'") try: response = await self.client.get(url, params=params, headers=headers) logger.info(f"Sonarr response status: {response.status_code}") if response.status_code == 401: logger.error("Sonarr authentication failed - check your API key") raise Exception("Sonarr authentication failed - verify your API key is correct") elif response.status_code == 404: logger.error("Sonarr lookup endpoint not found") raise Exception("Sonarr lookup endpoint not found") response.raise_for_status() results = response.json() logger.info(f"Sonarr returned {len(results)} results for query '{query}'") if results: logger.info(f"First result: {results[0].get('title')} ({results[0].get('year', 'No year')})") return results except Exception as e: logger.error(f"Sonarr lookup error for query '{query}': {e}") raise e async def add_series_to_sonarr(self, tvdb_id: int, title: str, root_folder: Optional[str] = None) -> AddMediaResponse: """Add TV series to Sonarr using TVDB ID""" url = f"{self.config.sonarr_url}/api/v3/series" headers = {"X-Api-Key": self.config.sonarr_api_key} payload = { "title": title, "tvdbId": tvdb_id, "qualityProfileId": self.config.quality_profile_id, "monitored": True, "seasonFolder": True, "addOptions": { "searchForMissingEpisodes": True } } # Set root folder (parameter > config > auto-detect) if root_folder: payload["rootFolderPath"] = root_folder logger.info(f"Using specified root folder: {root_folder}") elif self.config.sonarr_root_folder: payload["rootFolderPath"] = self.config.sonarr_root_folder logger.info(f"Using configured root folder: {self.config.sonarr_root_folder}") else: # Auto-detect first available root folder root_folders = await self.get_sonarr_root_folders() if root_folders: payload["rootFolderPath"] = root_folders[0]["path"] logger.info(f"Using auto-detected Sonarr root folder: {root_folders[0]['path']}") else: logger.warning("No Sonarr root folders found - series may fail to add") try: response = await self.client.post(url, json=payload, headers=headers) if response.status_code == 201: result = response.json() return AddMediaResponse( success=True, message=f"Successfully added '{title}' to Sonarr", media_id=result.get("id") ) else: return AddMediaResponse( success=False, message=f"Failed to add series: {response.text}" ) except Exception as e: logger.error(f"Sonarr API error: {e}") return AddMediaResponse( success=False, message=f"Error communicating with Sonarr: {str(e)}" ) async def check_radarr_status(self) -> Dict[str, Any]: """Check Radarr server status""" url = f"{self.config.radarr_url}/api/v3/system/status" headers = {"X-Api-Key": self.config.radarr_api_key} try: response = await self.client.get(url, headers=headers) response.raise_for_status() return {"status": "connected", "data": response.json()} except Exception as e: return {"status": "error", "message": str(e)} async def check_sonarr_status(self) -> Dict[str, Any]: """Check Sonarr server status""" url = f"{self.config.sonarr_url}/api/v3/system/status" headers = {"X-Api-Key": self.config.sonarr_api_key} try: response = await self.client.get(url, headers=headers) response.raise_for_status() return {"status": "connected", "data": response.json()} except Exception as e: return {"status": "error", "message": str(e)} # MCP Tools @mcp.tool async def search_movies(title: str) -> Dict[str, Any]: """ Search for movies by title using Radarr's built-in lookup. Args: title: Movie title only (e.g., "The Matrix" or "Primer") Returns: Dict with search results """ logger.info(f"Searching for movies: '{title}'") if not config: error_msg = "Server not configured. Please set up Radarr API key." logger.error(error_msg) return {"error": error_msg, "results": []} if not config.radarr_api_key: error_msg = "Radarr API key not configured" logger.error(error_msg) return {"error": error_msg, "results": []} api = MediaServerAPI(config) try: logger.info(f"Searching Radarr for: {title}") radarr_results = await api.search_radarr_movies(title) logger.info(f"Radarr returned {len(radarr_results)} results") if not radarr_results: return { "message": f"No movies found matching '{title}'", "results": [], "searched_query": title } results = [] for movie in radarr_results[:10]: # Show more results since we're not auto-adding result = MediaSearchResult( title=movie.get("title", "Unknown"), year=movie.get("year"), overview=movie.get("overview", "No overview available"), tmdb_id=movie.get("tmdbId"), poster_path=movie.get("remotePoster"), media_type="movie" ) results.append(result) return { "results": [r.dict() for r in results], "total_found": len(results), "searched_query": title } except Exception as e: error_msg = f"Error during movie search: {str(e)}" logger.error(error_msg, exc_info=True) return {"error": error_msg, "results": []} @mcp.tool async def add_movie_by_id(tmdb_id: int, root_folder: Optional[str] = None) -> AddMediaResponse: """ Add a specific movie to Radarr using its TMDb ID. Args: tmdb_id: The Movie Database ID for the movie root_folder: Optional root folder path (e.g., "/storage/movies") Returns: Result of the add operation """ if not config: raise ValueError("Server not configured. Please set up Radarr API key.") api = MediaServerAPI(config) # Use TMDb ID as title placeholder - Radarr will fetch the real title title = f"Movie (TMDb ID: {tmdb_id})" return await api.add_movie_to_radarr(tmdb_id, title, root_folder) @mcp.tool async def search_and_add_show( description: str, auto_add: bool = False ) -> List[MediaSearchResult]: """ Search for TV shows using natural language description and optionally add to Sonarr. Args: description: Natural language description of the TV show (e.g., "British time travel show with the Doctor") auto_add: If True and only one result found, automatically add to Sonarr Returns: List of matching TV shows with metadata """ if not config: raise ValueError("Server not configured. Please set up Sonarr API key.") api = MediaServerAPI(config) # Search for TV shows using Sonarr lookup tv_results = await api.search_sonarr_shows(description) results = [] for show in tv_results[:5]: # Limit to top 5 results result = MediaSearchResult( title=show.get("title", "Unknown"), year=show.get("year"), overview=show.get("overview", "No overview available"), tmdb_id=show.get("tmdbId"), tvdb_id=show.get("tvdbId"), poster_path=show.get("remotePoster"), media_type="tv" ) results.append(result) # Auto-add if requested and only one result if auto_add and len(results) == 1: show = results[0] if show.tvdb_id: add_result = await api.add_series_to_sonarr(show.tvdb_id, show.title, show.tmdb_id) logger.info(f"Auto-add result: {add_result}") else: logger.warning(f"Cannot auto-add '{show.title}' - no TVDB ID available") return results @mcp.tool async def add_show_by_tvdb_id(tvdb_id: int, title: str, root_folder: Optional[str] = None) -> AddMediaResponse: """ Add a specific TV show to Sonarr using its TVDB ID. Args: tvdb_id: The TV Database ID for the show title: The title of the show root_folder: Optional root folder path (e.g., "/storage/anime") Returns: Result of the add operation """ if not config: raise ValueError("Server not configured. Please set up Sonarr API key.") api = MediaServerAPI(config) return await api.add_series_to_sonarr(tvdb_id, title, root_folder) @mcp.tool async def test_config() -> Dict[str, Any]: """ Test the current configuration and API connectivity. Returns: Configuration status and basic connectivity tests """ logger.info("Testing configuration...") if not config: return {"error": "No configuration loaded"} status = { "config_loaded": True, "radarr_url": config.radarr_url, "sonarr_url": config.sonarr_url, "radarr_api_key_set": bool(config.radarr_api_key), "sonarr_api_key_set": bool(config.sonarr_api_key), "tvdb_api_key_set": bool(config.tvdb_api_key), "quality_profile_id": config.quality_profile_id, "radarr_root_folder": config.radarr_root_folder, "sonarr_root_folder": config.sonarr_root_folder } # Test Radarr connectivity if config.radarr_api_key: try: api = MediaServerAPI(config) test_results = await api.search_radarr_movies("test") status["radarr_search_connectivity"] = "success" status["radarr_test_results"] = len(test_results) except Exception as e: status["radarr_search_connectivity"] = "failed" status["radarr_search_error"] = str(e) else: status["radarr_search_connectivity"] = "no_api_key" return status @mcp.tool async def get_server_status() -> Dict[str, Any]: """ Check the status and connectivity of Radarr and Sonarr servers. Returns: Status information for both servers """ if not config: return {"error": "Server not configured"} api = MediaServerAPI(config) radarr_status = await api.check_radarr_status() sonarr_status = await api.check_sonarr_status() return { "radarr": radarr_status, "sonarr": sonarr_status, "timestamp": datetime.now().isoformat() } def setup_config( radarr_url: str, radarr_api_key: str, sonarr_url: str, sonarr_api_key: str, tvdb_api_key: Optional[str] = None, quality_profile_id: int = 1, radarr_root_folder: Optional[str] = None, sonarr_root_folder: Optional[str] = None ): """Setup server configuration""" global config config = ServerConfig( radarr_url=radarr_url, radarr_api_key=radarr_api_key, sonarr_url=sonarr_url, sonarr_api_key=sonarr_api_key, tvdb_api_key=tvdb_api_key, quality_profile_id=quality_profile_id, radarr_root_folder=radarr_root_folder, sonarr_root_folder=sonarr_root_folder ) if __name__ == "__main__": import os # Load configuration from environment variables setup_config( radarr_url=os.getenv("RADARR_URL", "http://localhost:7878"), radarr_api_key=os.getenv("RADARR_API_KEY", ""), sonarr_url=os.getenv("SONARR_URL", "http://localhost:8989"), sonarr_api_key=os.getenv("SONARR_API_KEY", ""), tvdb_api_key=os.getenv("TVDB_API_KEY"), quality_profile_id=int(os.getenv("QUALITY_PROFILE_ID", "1")), radarr_root_folder=os.getenv("RADARR_ROOT_FOLDER"), sonarr_root_folder=os.getenv("SONARR_ROOT_FOLDER") ) mcp.run()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/omniwaifu/arr-assistant-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server