diff --git a/agent/api/__init__.py b/agent/api/__init__.py index 585f7f3..6967146 100644 --- a/agent/api/__init__.py +++ b/agent/api/__init__.py @@ -19,6 +19,16 @@ from .knaben import ( TorrentResult ) +from .qbittorrent import ( + QBittorrentClient, + qbittorrent_client, + QBittorrentError, + QBittorrentConfigurationError, + QBittorrentAPIError, + QBittorrentAuthError, + TorrentInfo +) + __all__ = [ # TMDB 'TMDBClient', @@ -35,5 +45,13 @@ __all__ = [ 'KnabenConfigurationError', 'KnabenAPIError', 'KnabenNotFoundError', - 'TorrentResult' + 'TorrentResult', + # qBittorrent + 'QBittorrentClient', + 'qbittorrent_client', + 'QBittorrentError', + 'QBittorrentConfigurationError', + 'QBittorrentAPIError', + 'QBittorrentAuthError', + 'TorrentInfo' ] diff --git a/agent/api/qbittorrent.py b/agent/api/qbittorrent.py new file mode 100644 index 0000000..b82cd8c --- /dev/null +++ b/agent/api/qbittorrent.py @@ -0,0 +1,429 @@ +"""qBittorrent Web API client.""" +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +import logging +import requests +from requests.exceptions import RequestException, Timeout, HTTPError + +from ..config import Settings, settings + +logger = logging.getLogger(__name__) + + +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 + + +@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: Optional[str] = None + save_path: Optional[str] = None + + +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: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: Optional[int] = None, + config: Optional[Settings] = 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: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = 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: Optional[str] = None, + category: Optional[str] = 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: Optional[str] = None, + save_path: Optional[str] = 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(f"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: + response = 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") + ) + + +# Global qBittorrent client instance (singleton) +qbittorrent_client = QBittorrentClient() diff --git a/agent/registry.py b/agent/registry.py index 8210646..44914fc 100644 --- a/agent/registry.py +++ b/agent/registry.py @@ -5,7 +5,7 @@ from functools import partial from .memory import Memory from .tools.filesystem import set_path_for_folder, list_folder -from .tools.api import find_media_imdb_id, find_torrent +from .tools.api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent @dataclass @@ -103,6 +103,21 @@ def make_tools(memory: Memory) -> Dict[str, Tool]: "required": ["media_title"] } ), + Tool( + name="add_torrent_to_qbittorrent", + description="Adds a torrent to qBittorrent client.", + func=add_torrent_to_qbittorrent, + parameters={ + "type": "object", + "properties": { + "magnet_link": { + "type": "string", + "description": "Title of the media to find torrents for" + }, + }, + "required": ["magnet_link"] + } + ), ] return {t.name: t for t in tools} diff --git a/agent/tools/api.py b/agent/tools/api.py index 4051d73..78ac27e 100644 --- a/agent/tools/api.py +++ b/agent/tools/api.py @@ -4,6 +4,7 @@ import logging from ..api import tmdb_client, TMDBError, TMDBNotFoundError, TMDBAPIError, TMDBConfigurationError from ..api.knaben import knaben_client, KnabenError, KnabenNotFoundError, KnabenAPIError +from ..api.qbittorrent import qbittorrent_client, QBittorrentError, QBittorrentAuthError, QBittorrentAPIError logger = logging.getLogger(__name__) @@ -90,6 +91,7 @@ def find_media_imdb_id(media_title: str) -> Dict[str, Any]: "message": "An unexpected error occurred" } + def find_torrent(media_title: str) -> Dict[str, Any]: """ Find torrents for a given media title using Knaben API. @@ -166,3 +168,78 @@ def find_torrent(media_title: str) -> Dict[str, Any]: "error": "internal_error", "message": "An unexpected error occurred" } + + +def add_torrent_to_qbittorrent(magnet_link: str) -> Dict[str, Any]: + """ + Add a torrent to qBittorrent using a magnet link. + + This is a wrapper around the qBittorrent client that returns a standardized + dict format for compatibility with the agent's tool system. + + Args: + magnet_link: Magnet link of the torrent to add + + Returns: + Dict with success or error information: + - Success: {"status": "ok", "message": str} + - Error: {"error": str, "message": str} + + Example: + >>> result = add_torrent_to_qbittorrent("magnet:?xt=urn:btih:...") + >>> print(result) + {'status': 'ok', 'message': 'Torrent added successfully'} + """ + try: + # Validate magnet link + if not magnet_link or not isinstance(magnet_link, str): + raise ValueError("Magnet link must be a non-empty string") + + if not magnet_link.startswith("magnet:"): + raise ValueError("Invalid magnet link format") + + logger.info("Adding torrent to qBittorrent") + + # Add torrent to qBittorrent + success = qbittorrent_client.add_torrent(magnet_link) + + if success: + logger.info("Torrent added successfully to qBittorrent") + return { + "status": "ok", + "message": "Torrent added successfully to qBittorrent" + } + else: + logger.warning("Failed to add torrent to qBittorrent") + return { + "error": "add_failed", + "message": "Failed to add torrent to qBittorrent" + } + + except QBittorrentAuthError as e: + logger.error(f"qBittorrent authentication error: {e}") + return { + "error": "authentication_failed", + "message": "Failed to authenticate with qBittorrent" + } + + except QBittorrentAPIError as e: + logger.error(f"qBittorrent API error: {e}") + return { + "error": "api_error", + "message": str(e) + } + + except ValueError as e: + logger.error(f"Validation error: {e}") + return { + "error": "validation_failed", + "message": str(e) + } + + except Exception as e: + logger.error(f"Unexpected error: {e}", exc_info=True) + return { + "error": "internal_error", + "message": "An unexpected error occurred" + }