"""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, )