infra: reorganized repo
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API clients for external services."""
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Knaben API client."""
|
||||
|
||||
from .client import KnabenClient
|
||||
from .dto import TorrentResult
|
||||
from .exceptions import (
|
||||
KnabenAPIError,
|
||||
KnabenConfigurationError,
|
||||
KnabenError,
|
||||
KnabenNotFoundError,
|
||||
)
|
||||
|
||||
# Global Knaben client instance (singleton)
|
||||
knaben_client = KnabenClient()
|
||||
|
||||
__all__ = [
|
||||
"KnabenClient",
|
||||
"TorrentResult",
|
||||
"KnabenError",
|
||||
"KnabenConfigurationError",
|
||||
"KnabenAPIError",
|
||||
"KnabenNotFoundError",
|
||||
"knaben_client",
|
||||
]
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Knaben torrent search API client."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from agent.config import Settings, settings
|
||||
|
||||
from .dto import TorrentResult
|
||||
from .exceptions import KnabenAPIError, KnabenNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnabenClient:
|
||||
"""
|
||||
Client for interacting with Knaben torrent search API.
|
||||
|
||||
Knaben is a torrent search engine that aggregates results from multiple trackers.
|
||||
|
||||
Example:
|
||||
>>> client = KnabenClient()
|
||||
>>> results = client.search("Inception 1080p")
|
||||
>>> for torrent in results[:5]:
|
||||
... print(f"{torrent.name} - Seeders: {torrent.seeders}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
timeout: int | None = None,
|
||||
config: Settings | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Knaben client.
|
||||
|
||||
Args:
|
||||
base_url: Knaben API base URL (defaults to https://api.knaben.org/v1)
|
||||
timeout: Request timeout in seconds (defaults to settings)
|
||||
config: Optional Settings instance (for testing)
|
||||
|
||||
Note:
|
||||
Knaben API doesn't require an API key
|
||||
"""
|
||||
cfg = config or settings
|
||||
|
||||
self.base_url = base_url or "https://api.knaben.org/v1"
|
||||
self.timeout = timeout or cfg.request_timeout
|
||||
|
||||
logger.info("Knaben client initialized")
|
||||
|
||||
def _make_request(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Make a request to Knaben API.
|
||||
|
||||
Args:
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
JSON response as dict
|
||||
|
||||
Raises:
|
||||
KnabenAPIError: If request fails
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Knaben request with params: {params}")
|
||||
response = requests.post(self.base_url, json=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Timeout as e:
|
||||
logger.error(f"Knaben API timeout: {e}")
|
||||
raise KnabenAPIError(f"Request timeout after {self.timeout} seconds") from e
|
||||
|
||||
except HTTPError as e:
|
||||
logger.error(f"Knaben API HTTP error: {e}")
|
||||
if e.response is not None:
|
||||
status_code = e.response.status_code
|
||||
if status_code == 404:
|
||||
raise KnabenNotFoundError("Resource not found") from e
|
||||
elif status_code == 429:
|
||||
raise KnabenAPIError("Rate limit exceeded") from e
|
||||
else:
|
||||
raise KnabenAPIError(f"HTTP {status_code}: {e}") from e
|
||||
raise KnabenAPIError(f"HTTP error: {e}") from e
|
||||
|
||||
except RequestException as e:
|
||||
logger.error(f"Knaben API request failed: {e}")
|
||||
raise KnabenAPIError(f"Failed to connect to Knaben API: {e}") from e
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> list[TorrentResult]:
|
||||
"""
|
||||
Search for torrents.
|
||||
|
||||
Args:
|
||||
query: Search query (e.g., "Inception 1080p")
|
||||
limit: Maximum number of results (default: 10)
|
||||
|
||||
Returns:
|
||||
List of TorrentResult objects
|
||||
|
||||
Raises:
|
||||
KnabenAPIError: If request fails
|
||||
ValueError: If query is invalid
|
||||
"""
|
||||
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)")
|
||||
|
||||
# Build params
|
||||
params = {
|
||||
"query": query,
|
||||
"search_field": "title",
|
||||
"order_by": "peers",
|
||||
"order_direction": "desc",
|
||||
"from": 0,
|
||||
"size": limit,
|
||||
"hide_unsafe": True,
|
||||
"hide_xxx": True,
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._make_request(params)
|
||||
except KnabenNotFoundError:
|
||||
logger.info(f"No torrents found for '{query}'")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in search: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Parse results
|
||||
results = []
|
||||
torrents = data.get("hits", [])
|
||||
|
||||
if not torrents:
|
||||
logger.info(f"No torrents found for '{query}'")
|
||||
return []
|
||||
|
||||
for torrent in torrents:
|
||||
try:
|
||||
result = self._parse_torrent(torrent)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse torrent: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(results)} torrents for '{query}'")
|
||||
return results
|
||||
|
||||
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentResult:
|
||||
"""
|
||||
Parse a torrent result into a TorrentResult object.
|
||||
|
||||
Args:
|
||||
torrent: Raw torrent dict from API
|
||||
|
||||
Returns:
|
||||
TorrentResult object
|
||||
"""
|
||||
# Extract required fields (API uses camelCase)
|
||||
title = torrent.get("title", "Unknown")
|
||||
size = torrent.get("size", "Unknown")
|
||||
seeders = int(torrent.get("seeders", 0) or 0)
|
||||
leechers = int(torrent.get("leechers", 0) or 0)
|
||||
magnet = torrent.get("magnetUrl", "")
|
||||
|
||||
# Extract optional fields
|
||||
info_hash = torrent.get("hash")
|
||||
tracker = torrent.get("tracker")
|
||||
upload_date = torrent.get("date")
|
||||
category = torrent.get("category")
|
||||
|
||||
return TorrentResult(
|
||||
title=title,
|
||||
size=size,
|
||||
seeders=seeders,
|
||||
leechers=leechers,
|
||||
magnet=magnet,
|
||||
info_hash=info_hash,
|
||||
tracker=tracker,
|
||||
upload_date=upload_date,
|
||||
category=category,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Knaben Data Transfer Objects."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentResult:
|
||||
"""Represents a torrent search result from Knaben."""
|
||||
|
||||
title: str
|
||||
size: str
|
||||
seeders: int
|
||||
leechers: int
|
||||
magnet: str
|
||||
info_hash: str | None = None
|
||||
tracker: str | None = None
|
||||
upload_date: str | None = None
|
||||
category: str | None = None
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Knaben API exceptions."""
|
||||
|
||||
|
||||
class KnabenError(Exception):
|
||||
"""Base exception for Knaben-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KnabenConfigurationError(KnabenError):
|
||||
"""Raised when Knaben API is not properly configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KnabenAPIError(KnabenError):
|
||||
"""Raised when Knaben API returns an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KnabenNotFoundError(KnabenError):
|
||||
"""Raised when no torrents are found."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,23 @@
|
||||
"""qBittorrent API client."""
|
||||
|
||||
from .client import QBittorrentClient
|
||||
from .dto import TorrentInfo
|
||||
from .exceptions import (
|
||||
QBittorrentAPIError,
|
||||
QBittorrentAuthError,
|
||||
QBittorrentConfigurationError,
|
||||
QBittorrentError,
|
||||
)
|
||||
|
||||
# Global qBittorrent client instance (singleton)
|
||||
qbittorrent_client = QBittorrentClient()
|
||||
|
||||
__all__ = [
|
||||
"QBittorrentClient",
|
||||
"TorrentInfo",
|
||||
"QBittorrentError",
|
||||
"QBittorrentConfigurationError",
|
||||
"QBittorrentAPIError",
|
||||
"QBittorrentAuthError",
|
||||
"qbittorrent_client",
|
||||
]
|
||||
@@ -0,0 +1,385 @@
|
||||
"""qBittorrent Web API client."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from agent.config import Settings, settings
|
||||
|
||||
from .dto import TorrentInfo
|
||||
from .exceptions import QBittorrentAPIError, QBittorrentAuthError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QBittorrentClient:
|
||||
"""
|
||||
Client for interacting with qBittorrent Web API.
|
||||
|
||||
This client provides methods to manage torrents in qBittorrent.
|
||||
|
||||
Example:
|
||||
>>> client = QBittorrentClient()
|
||||
>>> client.login()
|
||||
>>> torrents = client.get_torrents()
|
||||
>>> for torrent in torrents:
|
||||
... print(f"{torrent.name} - {torrent.progress}%")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
timeout: int | None = None,
|
||||
config: Settings | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize qBittorrent client.
|
||||
|
||||
Args:
|
||||
host: qBittorrent host URL (e.g., "http://192.168.1.100:8080")
|
||||
username: qBittorrent username
|
||||
password: qBittorrent password
|
||||
timeout: Request timeout in seconds (defaults to settings)
|
||||
config: Optional Settings instance (for testing)
|
||||
"""
|
||||
cfg = config or settings
|
||||
|
||||
self.host = host or "http://192.168.178.47:30024"
|
||||
self.username = username or "admin"
|
||||
self.password = password or "adminadmin"
|
||||
self.timeout = timeout or cfg.request_timeout
|
||||
|
||||
self.session = requests.Session()
|
||||
self._authenticated = False
|
||||
|
||||
logger.info(f"qBittorrent client initialized for {self.host}")
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
files: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make a request to qBittorrent API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST)
|
||||
endpoint: API endpoint (e.g., '/api/v2/torrents/info')
|
||||
data: Request data
|
||||
files: Files to upload
|
||||
|
||||
Returns:
|
||||
Response (JSON or text)
|
||||
|
||||
Raises:
|
||||
QBittorrentAPIError: If request fails
|
||||
"""
|
||||
url = f"{self.host}{endpoint}"
|
||||
|
||||
try:
|
||||
logger.debug(f"qBittorrent {method} request: {endpoint}")
|
||||
|
||||
if method.upper() == "GET":
|
||||
response = self.session.get(url, params=data, timeout=self.timeout)
|
||||
elif method.upper() == "POST":
|
||||
response = self.session.post(
|
||||
url, data=data, files=files, timeout=self.timeout
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Try to parse as JSON, otherwise return text
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
except Timeout as e:
|
||||
logger.error(f"qBittorrent API timeout: {e}")
|
||||
raise QBittorrentAPIError(
|
||||
f"Request timeout after {self.timeout} seconds"
|
||||
) from e
|
||||
|
||||
except HTTPError as e:
|
||||
logger.error(f"qBittorrent API HTTP error: {e}")
|
||||
if e.response is not None:
|
||||
status_code = e.response.status_code
|
||||
if status_code == 403:
|
||||
raise QBittorrentAuthError(
|
||||
"Authentication required or forbidden"
|
||||
) from e
|
||||
else:
|
||||
raise QBittorrentAPIError(f"HTTP {status_code}: {e}") from e
|
||||
raise QBittorrentAPIError(f"HTTP error: {e}") from e
|
||||
|
||||
except RequestException as e:
|
||||
logger.error(f"qBittorrent API request failed: {e}")
|
||||
raise QBittorrentAPIError(f"Failed to connect to qBittorrent: {e}") from e
|
||||
|
||||
def login(self) -> bool:
|
||||
"""
|
||||
Authenticate with qBittorrent.
|
||||
|
||||
Returns:
|
||||
True if authentication successful
|
||||
|
||||
Raises:
|
||||
QBittorrentAuthError: If authentication fails
|
||||
"""
|
||||
try:
|
||||
data = {"username": self.username, "password": self.password}
|
||||
|
||||
response = self._make_request("POST", "/api/v2/auth/login", data=data)
|
||||
|
||||
if response == "Ok.":
|
||||
self._authenticated = True
|
||||
logger.info("Successfully authenticated with qBittorrent")
|
||||
return True
|
||||
else:
|
||||
raise QBittorrentAuthError("Authentication failed")
|
||||
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Login failed: {e}")
|
||||
raise QBittorrentAuthError("Failed to authenticate") from e
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""
|
||||
Logout from qBittorrent.
|
||||
|
||||
Returns:
|
||||
True if logout successful
|
||||
"""
|
||||
try:
|
||||
self._make_request("POST", "/api/v2/auth/logout")
|
||||
self._authenticated = False
|
||||
logger.info("Logged out from qBittorrent")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Logout failed: {e}")
|
||||
return False
|
||||
|
||||
def get_torrents(
|
||||
self, filter: str | None = None, category: str | None = None
|
||||
) -> list[TorrentInfo]:
|
||||
"""
|
||||
Get list of torrents.
|
||||
|
||||
Args:
|
||||
filter: Filter torrents (all, downloading, completed, paused, active, inactive)
|
||||
category: Filter by category
|
||||
|
||||
Returns:
|
||||
List of TorrentInfo objects
|
||||
|
||||
Raises:
|
||||
QBittorrentAPIError: If request fails
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
params = {}
|
||||
if filter:
|
||||
params["filter"] = filter
|
||||
if category:
|
||||
params["category"] = category
|
||||
|
||||
try:
|
||||
data = self._make_request("GET", "/api/v2/torrents/info", data=params)
|
||||
|
||||
if not isinstance(data, list):
|
||||
logger.warning("Unexpected response format")
|
||||
return []
|
||||
|
||||
torrents = []
|
||||
for torrent in data:
|
||||
try:
|
||||
torrents.append(self._parse_torrent(torrent))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse torrent: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Retrieved {len(torrents)} torrents")
|
||||
return torrents
|
||||
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to get torrents: {e}")
|
||||
raise
|
||||
|
||||
def add_torrent(
|
||||
self,
|
||||
magnet: str,
|
||||
category: str | None = None,
|
||||
save_path: str | None = None,
|
||||
paused: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a torrent via magnet link.
|
||||
|
||||
Args:
|
||||
magnet: Magnet link
|
||||
category: Category to assign
|
||||
save_path: Download path
|
||||
paused: Start torrent paused
|
||||
|
||||
Returns:
|
||||
True if torrent added successfully
|
||||
|
||||
Raises:
|
||||
QBittorrentAPIError: If request fails
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
data = {"urls": magnet, "paused": "true" if paused else "false"}
|
||||
|
||||
if category:
|
||||
data["category"] = category
|
||||
if save_path:
|
||||
data["savepath"] = save_path
|
||||
|
||||
try:
|
||||
response = self._make_request("POST", "/api/v2/torrents/add", data=data)
|
||||
|
||||
if response == "Ok.":
|
||||
logger.info("Successfully added torrent")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Unexpected response: {response}")
|
||||
return False
|
||||
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to add torrent: {e}")
|
||||
raise
|
||||
|
||||
def delete_torrent(self, torrent_hash: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Delete a torrent.
|
||||
|
||||
Args:
|
||||
torrent_hash: Hash of the torrent
|
||||
delete_files: Also delete downloaded files
|
||||
|
||||
Returns:
|
||||
True if torrent deleted successfully
|
||||
|
||||
Raises:
|
||||
QBittorrentAPIError: If request fails
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
data = {
|
||||
"hashes": torrent_hash,
|
||||
"deleteFiles": "true" if delete_files else "false",
|
||||
}
|
||||
|
||||
try:
|
||||
self._make_request("POST", "/api/v2/torrents/delete", data=data)
|
||||
logger.info(f"Deleted torrent {torrent_hash}")
|
||||
return True
|
||||
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to delete torrent: {e}")
|
||||
raise
|
||||
|
||||
def pause_torrent(self, torrent_hash: str) -> bool:
|
||||
"""
|
||||
Pause a torrent.
|
||||
|
||||
Args:
|
||||
torrent_hash: Hash of the torrent
|
||||
|
||||
Returns:
|
||||
True if torrent paused successfully
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
data = {"hashes": torrent_hash}
|
||||
|
||||
try:
|
||||
self._make_request("POST", "/api/v2/torrents/pause", data=data)
|
||||
logger.info(f"Paused torrent {torrent_hash}")
|
||||
return True
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to pause torrent: {e}")
|
||||
raise
|
||||
|
||||
def resume_torrent(self, torrent_hash: str) -> bool:
|
||||
"""
|
||||
Resume a torrent.
|
||||
|
||||
Args:
|
||||
torrent_hash: Hash of the torrent
|
||||
|
||||
Returns:
|
||||
True if torrent resumed successfully
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
data = {"hashes": torrent_hash}
|
||||
|
||||
try:
|
||||
self._make_request("POST", "/api/v2/torrents/resume", data=data)
|
||||
logger.info(f"Resumed torrent {torrent_hash}")
|
||||
return True
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to resume torrent: {e}")
|
||||
raise
|
||||
|
||||
def get_torrent_properties(self, torrent_hash: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get detailed properties of a torrent.
|
||||
|
||||
Args:
|
||||
torrent_hash: Hash of the torrent
|
||||
|
||||
Returns:
|
||||
Dict with torrent properties
|
||||
"""
|
||||
if not self._authenticated:
|
||||
self.login()
|
||||
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
try:
|
||||
data = self._make_request("GET", "/api/v2/torrents/properties", data=params)
|
||||
return data
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"Failed to get torrent properties: {e}")
|
||||
raise
|
||||
|
||||
def _parse_torrent(self, torrent: dict[str, Any]) -> TorrentInfo:
|
||||
"""
|
||||
Parse a torrent dict into a TorrentInfo object.
|
||||
|
||||
Args:
|
||||
torrent: Raw torrent dict from API
|
||||
|
||||
Returns:
|
||||
TorrentInfo object
|
||||
"""
|
||||
return TorrentInfo(
|
||||
hash=torrent.get("hash", ""),
|
||||
name=torrent.get("name", "Unknown"),
|
||||
size=torrent.get("size", 0),
|
||||
progress=torrent.get("progress", 0.0) * 100, # Convert to percentage
|
||||
state=torrent.get("state", "unknown"),
|
||||
download_speed=torrent.get("dlspeed", 0),
|
||||
upload_speed=torrent.get("upspeed", 0),
|
||||
eta=torrent.get("eta", 0),
|
||||
num_seeds=torrent.get("num_seeds", 0),
|
||||
num_leechs=torrent.get("num_leechs", 0),
|
||||
ratio=torrent.get("ratio", 0.0),
|
||||
category=torrent.get("category"),
|
||||
save_path=torrent.get("save_path"),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""qBittorrent Data Transfer Objects."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""Represents a torrent in qBittorrent."""
|
||||
|
||||
hash: str
|
||||
name: str
|
||||
size: int
|
||||
progress: float
|
||||
state: str
|
||||
download_speed: int
|
||||
upload_speed: int
|
||||
eta: int
|
||||
num_seeds: int
|
||||
num_leechs: int
|
||||
ratio: float
|
||||
category: str | None = None
|
||||
save_path: str | None = None
|
||||
@@ -0,0 +1,25 @@
|
||||
"""qBittorrent API exceptions."""
|
||||
|
||||
|
||||
class QBittorrentError(Exception):
|
||||
"""Base exception for qBittorrent-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class QBittorrentConfigurationError(QBittorrentError):
|
||||
"""Raised when qBittorrent is not properly configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class QBittorrentAPIError(QBittorrentError):
|
||||
"""Raised when qBittorrent API returns an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class QBittorrentAuthError(QBittorrentError):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,24 @@
|
||||
"""TMDB API client."""
|
||||
|
||||
from .client import TMDBClient
|
||||
from .dto import ExternalIds, MediaResult
|
||||
from .exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
TMDBError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
# Global TMDB client instance (singleton)
|
||||
tmdb_client = TMDBClient()
|
||||
|
||||
__all__ = [
|
||||
"TMDBClient",
|
||||
"MediaResult",
|
||||
"ExternalIds",
|
||||
"TMDBError",
|
||||
"TMDBConfigurationError",
|
||||
"TMDBAPIError",
|
||||
"TMDBNotFoundError",
|
||||
"tmdb_client",
|
||||
]
|
||||
@@ -0,0 +1,289 @@
|
||||
"""TMDB (The Movie Database) API client."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from agent.config import Settings, settings
|
||||
|
||||
from .dto import MediaResult
|
||||
from .exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
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: 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: 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
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""TMDB Data Transfer Objects."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaResult:
|
||||
"""Represents a media search result from TMDB."""
|
||||
|
||||
tmdb_id: int
|
||||
title: str
|
||||
media_type: str # 'movie' or 'tv'
|
||||
imdb_id: str | None = None
|
||||
overview: str | None = None
|
||||
release_date: str | None = None
|
||||
poster_path: str | None = None
|
||||
vote_average: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalIds:
|
||||
"""External IDs for a media item."""
|
||||
|
||||
imdb_id: str | None = None
|
||||
tvdb_id: int | None = None
|
||||
facebook_id: str | None = None
|
||||
instagram_id: str | None = None
|
||||
twitter_id: str | None = None
|
||||
@@ -0,0 +1,25 @@
|
||||
"""TMDB API exceptions."""
|
||||
|
||||
|
||||
class TMDBError(Exception):
|
||||
"""Base exception for TMDB-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TMDBConfigurationError(TMDBError):
|
||||
"""Raised when TMDB API is not properly configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TMDBAPIError(TMDBError):
|
||||
"""Raised when TMDB API returns an error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TMDBNotFoundError(TMDBError):
|
||||
"""Raised when media is not found."""
|
||||
|
||||
pass
|
||||
Reference in New Issue
Block a user