feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)
- Fix circular dependencies in agent/tools - Migrate from custom JSON to OpenAI tool calls format - Add async streaming (step_stream, complete_stream) - Simplify prompt system and remove token counting - Add 5 new API endpoints (/health, /v1/models, /api/memory/*) - Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language) - Fix all 500 tests and add coverage config (80% threshold) - Add comprehensive docs (README, pytest guide) BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
"""TMDB (The Movie Database) API client."""
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from agent.config import Settings, settings
|
||||
|
||||
from .dto import MediaResult
|
||||
from .exceptions import TMDBError, TMDBConfigurationError, TMDBAPIError, TMDBNotFoundError
|
||||
from .exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,88 +21,86 @@ 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
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout: int | None = None,
|
||||
config: Settings | None = 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]:
|
||||
self, endpoint: str, params: dict[str, Any] | None = 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
|
||||
|
||||
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:
|
||||
@@ -107,129 +112,133 @@ class TMDBClient:
|
||||
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]]:
|
||||
|
||||
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', [])
|
||||
|
||||
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]:
|
||||
|
||||
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'")
|
||||
|
||||
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:
|
||||
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']
|
||||
|
||||
|
||||
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'):
|
||||
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:
|
||||
|
||||
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')
|
||||
|
||||
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')
|
||||
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})")
|
||||
|
||||
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,
|
||||
@@ -238,43 +247,43 @@ class TMDBClient:
|
||||
overview=overview,
|
||||
release_date=release_date,
|
||||
poster_path=poster_path,
|
||||
vote_average=vote_average
|
||||
vote_average=vote_average,
|
||||
)
|
||||
|
||||
def get_movie_details(self, movie_id: int) -> Dict[str, Any]:
|
||||
|
||||
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]:
|
||||
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}')
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user