386 lines
11 KiB
Python
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.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"),
|
|
)
|