Updated folder structure (for Docker)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user