feat: add torrent client interface with qbittorrent support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Alexander
2026-04-28 18:05:36 +02:00
parent ff9ca7ecce
commit f77806ba46
3 changed files with 324 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TorrentClientError {
#[error("authentication failed")]
AuthenticationFailed,
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("torrent not found: {0}")]
TorrentNotFound(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("unexpected error: {0}")]
Unexpected(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TorrentState {
Downloading,
Seeding,
Paused,
Queued,
Checking,
Error,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TorrentInfo {
pub hash: String,
pub name: String,
pub size: u64,
pub progress: f64,
pub download_speed: u64,
pub upload_speed: u64,
pub state: TorrentState,
pub save_path: String,
}
#[async_trait]
pub trait TorrentClient: Send + Sync {
async fn connect(&mut self) -> Result<(), TorrentClientError>;
async fn disconnect(&mut self) -> Result<(), TorrentClientError>;
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError>;
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError>;
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError>;
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError>;
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
}
+5
View File
@@ -0,0 +1,5 @@
mod client;
mod qbittorrent;
pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
pub use qbittorrent::QBittorrentClient;
+250
View File
@@ -0,0 +1,250 @@
use async_trait::async_trait;
use reqwest::{multipart, Client};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
use url::Url;
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
pub struct QBittorrentClient {
base_url: Url,
username: String,
password: String,
http: Client,
connected: Arc<RwLock<bool>>,
}
#[derive(Debug, Deserialize)]
struct QBTorrent {
hash: String,
name: String,
size: i64,
progress: f64,
dlspeed: i64,
upspeed: i64,
state: String,
save_path: String,
}
impl QBittorrentClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
let base_url = Url::parse(base_url)
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let http = Client::builder()
.cookie_store(true)
.build()?;
Ok(Self {
base_url,
username: username.to_string(),
password: password.to_string(),
http,
connected: Arc::new(RwLock::new(false)),
})
}
fn api_url(&self, path: &str) -> String {
format!("{}api/v2{}", self.base_url, path)
}
fn map_state(state: &str) -> TorrentState {
match state {
"downloading" | "forcedDL" | "metaDL" | "allocating" => TorrentState::Downloading,
"uploading" | "forcedUP" | "stalledUP" => TorrentState::Seeding,
"pausedDL" | "pausedUP" => TorrentState::Paused,
"queuedDL" | "queuedUP" => TorrentState::Queued,
"checkingDL" | "checkingUP" | "checkingResumeData" => TorrentState::Checking,
"error" | "missingFiles" => TorrentState::Error,
_ => TorrentState::Unknown,
}
}
fn map_torrent(t: QBTorrent) -> TorrentInfo {
TorrentInfo {
hash: t.hash,
name: t.name,
size: t.size.max(0) as u64,
progress: t.progress,
download_speed: t.dlspeed.max(0) as u64,
upload_speed: t.upspeed.max(0) as u64,
state: Self::map_state(&t.state),
save_path: t.save_path,
}
}
async fn ensure_connected(&self) -> Result<(), TorrentClientError> {
let connected = *self.connected.read().await;
if !connected {
return Err(TorrentClientError::ConnectionFailed("not connected".into()));
}
Ok(())
}
}
#[async_trait]
impl TorrentClient for QBittorrentClient {
async fn connect(&mut self) -> Result<(), TorrentClientError> {
let params = [
("username", self.username.as_str()),
("password", self.password.as_str()),
];
let resp = self
.http
.post(self.api_url("/auth/login"))
.form(&params)
.send()
.await?;
let text = resp.text().await?;
if text == "Ok." {
*self.connected.write().await = true;
Ok(())
} else {
Err(TorrentClientError::AuthenticationFailed)
}
}
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
self.http
.post(self.api_url("/auth/logout"))
.send()
.await?;
*self.connected.write().await = false;
Ok(())
}
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.get(self.api_url("/torrents/info"))
.send()
.await?;
let torrents: Vec<QBTorrent> = resp.json().await?;
Ok(torrents.into_iter().map(Self::map_torrent).collect())
}
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.get(self.api_url("/torrents/info"))
.query(&[("hashes", hash)])
.send()
.await?;
let torrents: Vec<QBTorrent> = resp.json().await?;
torrents
.into_iter()
.next()
.map(Self::map_torrent)
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
}
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let mut form = multipart::Form::new().text("urls", url.to_string());
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
let resp = self
.http
.post(self.api_url("/torrents/add"))
.multipart(form)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::InvalidRequest(
resp.text().await.unwrap_or_default(),
))
}
}
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let part = multipart::Part::bytes(torrent_data.to_vec())
.file_name("torrent.torrent")
.mime_str("application/x-bittorrent")
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let mut form = multipart::Form::new().part("torrents", part);
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
let resp = self
.http
.post(self.api_url("/torrents/add"))
.multipart(form)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::InvalidRequest(
resp.text().await.unwrap_or_default(),
))
}
}
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.post(self.api_url("/torrents/delete"))
.form(&[
("hashes", hash),
("deleteFiles", if delete_files { "true" } else { "false" }),
])
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
}
}
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
self.http
.post(self.api_url("/torrents/pause"))
.form(&[("hashes", hash)])
.send()
.await?;
Ok(())
}
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
self.http
.post(self.api_url("/torrents/resume"))
.form(&[("hashes", hash)])
.send()
.await?;
Ok(())
}
}