Files
alfred/alfred/infrastructure/api/qbittorrent/client.py

386 lines
11 KiB
Python

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