qBittorrent API
This commit is contained in:
@@ -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'
|
||||
]
|
||||
|
||||
429
agent/api/qbittorrent.py
Normal file
429
agent/api/qbittorrent.py
Normal file
@@ -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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user