"""qBittorrent Web API client.""" import logging from typing import Any import requests from requests.exceptions import HTTPError, RequestException, Timeout from alfred.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"), )