"""TMDB (The Movie Database) API client.""" from typing import Dict, Any, Optional, List import logging import requests from requests.exceptions import RequestException, Timeout, HTTPError from agent.config import Settings, settings from .dto import MediaResult from .exceptions import TMDBError, TMDBConfigurationError, TMDBAPIError, TMDBNotFoundError logger = logging.getLogger(__name__) class TMDBClient: """ Client for interacting with The Movie Database (TMDB) API. This client provides methods to search for movies and TV shows, retrieve their details, and get external IDs (like IMDb). Example: >>> client = TMDBClient() >>> result = client.search_media("Inception") >>> print(result.imdb_id) 'tt1375666' """ def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: Optional[int] = None, config: Optional[Settings] = None ): """ Initialize TMDB client. Args: api_key: TMDB API key (defaults to settings) base_url: TMDB API base URL (defaults to settings) timeout: Request timeout in seconds (defaults to settings) config: Optional Settings instance (for testing) Raises: TMDBConfigurationError: If API key is missing """ cfg = config or settings self.api_key = api_key or cfg.tmdb_api_key self.base_url = base_url or cfg.tmdb_base_url self.timeout = timeout or cfg.request_timeout if not self.api_key: raise TMDBConfigurationError( "TMDB API key is required. Set TMDB_API_KEY environment variable." ) if not self.base_url: raise TMDBConfigurationError( "TMDB base URL is required. Set TMDB_BASE_URL environment variable." ) logger.info("TMDB client initialized") def _make_request( self, endpoint: str, params: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Make a request to TMDB API. Args: endpoint: API endpoint (e.g., '/search/multi') params: Query parameters Returns: JSON response as dict Raises: TMDBAPIError: If request fails """ url = f"{self.base_url}{endpoint}" # Add API key to params request_params = params or {} request_params['api_key'] = self.api_key try: logger.debug(f"TMDB request: {endpoint}") response = requests.get(url, params=request_params, timeout=self.timeout) response.raise_for_status() return response.json() except Timeout as e: logger.error(f"TMDB API timeout: {e}") raise TMDBAPIError(f"Request timeout after {self.timeout} seconds") from e except HTTPError as e: logger.error(f"TMDB API HTTP error: {e}") if e.response is not None: status_code = e.response.status_code if status_code == 401: raise TMDBAPIError("Invalid TMDB API key") from e elif status_code == 404: raise TMDBNotFoundError("Resource not found") from e else: raise TMDBAPIError(f"HTTP {status_code}: {e}") from e raise TMDBAPIError(f"HTTP error: {e}") from e except RequestException as e: logger.error(f"TMDB API request failed: {e}") raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e def search_multi(self, query: str) -> List[Dict[str, Any]]: """ Search for movies and TV shows. Args: query: Search query (movie or TV show title) Returns: List of search results Raises: TMDBAPIError: If request fails TMDBNotFoundError: If no results found """ if not query or not isinstance(query, str): raise ValueError("Query must be a non-empty string") if len(query) > 500: raise ValueError("Query is too long (max 500 characters)") data = self._make_request('/search/multi', {'query': query}) results = data.get('results', []) if not results: raise TMDBNotFoundError(f"No results found for '{query}'") logger.info(f"Found {len(results)} results for '{query}'") return results def get_external_ids(self, media_type: str, tmdb_id: int) -> Dict[str, Any]: """ Get external IDs (IMDb, TVDB, etc.) for a media item. Args: media_type: Type of media ('movie' or 'tv') tmdb_id: TMDB ID of the media Returns: Dict with external IDs Raises: TMDBAPIError: If request fails """ if media_type not in ('movie', 'tv'): raise ValueError(f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'") endpoint = f"/{media_type}/{tmdb_id}/external_ids" return self._make_request(endpoint) def search_media(self, title: str) -> MediaResult: """ Search for a media item and return detailed information including IMDb ID. This is a convenience method that combines search and external ID lookup. Args: title: Title of the movie or TV show Returns: MediaResult with all available information Raises: TMDBAPIError: If request fails TMDBNotFoundError: If media not found """ # Search for media results = self.search_multi(title) # Get the first (most relevant) result top_result = results[0] # Validate result structure if 'id' not in top_result or 'media_type' not in top_result: raise TMDBAPIError("Invalid TMDB response structure") tmdb_id = top_result['id'] media_type = top_result['media_type'] # Skip if not movie or TV show if media_type not in ('movie', 'tv'): logger.warning(f"Skipping result of type: {media_type}") if len(results) > 1: # Try next result return self._parse_result(results[1]) raise TMDBNotFoundError(f"No movie or TV show found for '{title}'") return self._parse_result(top_result) def _parse_result(self, result: Dict[str, Any]) -> MediaResult: """ Parse a TMDB result into a MediaResult object. Args: result: Raw TMDB result dict Returns: MediaResult object """ tmdb_id = result['id'] media_type = result['media_type'] title = result.get('title') or result.get('name', 'Unknown') # Get external IDs (including IMDb) try: external_ids = self.get_external_ids(media_type, tmdb_id) imdb_id = external_ids.get('imdb_id') except TMDBAPIError as e: logger.warning(f"Failed to get external IDs: {e}") imdb_id = None # Extract other useful information overview = result.get('overview') release_date = result.get('release_date') or result.get('first_air_date') poster_path = result.get('poster_path') vote_average = result.get('vote_average') logger.info(f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})") return MediaResult( tmdb_id=tmdb_id, title=title, media_type=media_type, imdb_id=imdb_id, overview=overview, release_date=release_date, poster_path=poster_path, vote_average=vote_average ) def get_movie_details(self, movie_id: int) -> Dict[str, Any]: """ Get detailed information about a movie. Args: movie_id: TMDB movie ID Returns: Dict with movie details Raises: TMDBAPIError: If request fails """ return self._make_request(f'/movie/{movie_id}') def get_tv_details(self, tv_id: int) -> Dict[str, Any]: """ Get detailed information about a TV show. Args: tv_id: TMDB TV show ID Returns: Dict with TV show details Raises: TMDBAPIError: If request fails """ return self._make_request(f'/tv/{tv_id}') def is_configured(self) -> bool: """ Check if TMDB client is properly configured. Returns: True if configured, False otherwise """ return bool(self.api_key and self.base_url)