From f77806ba46e1850fde23664a45d99c0204dddfc7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 28 Apr 2026 18:05:36 +0200 Subject: [PATCH] feat: add torrent client interface with qbittorrent support Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus --- src/torrent/client.rs | 69 ++++++++++ src/torrent/mod.rs | 5 + src/torrent/qbittorrent.rs | 250 +++++++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/torrent/client.rs create mode 100644 src/torrent/mod.rs create mode 100644 src/torrent/qbittorrent.rs diff --git a/src/torrent/client.rs b/src/torrent/client.rs new file mode 100644 index 0000000..c69f893 --- /dev/null +++ b/src/torrent/client.rs @@ -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, TorrentClientError>; + + async fn get_torrent(&self, hash: &str) -> Result; + + 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>; +} diff --git a/src/torrent/mod.rs b/src/torrent/mod.rs new file mode 100644 index 0000000..c7de310 --- /dev/null +++ b/src/torrent/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod qbittorrent; + +pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState}; +pub use qbittorrent::QBittorrentClient; diff --git a/src/torrent/qbittorrent.rs b/src/torrent/qbittorrent.rs new file mode 100644 index 0000000..dc2d19b --- /dev/null +++ b/src/torrent/qbittorrent.rs @@ -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>, +} + +#[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 { + 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(¶ms) + .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, TorrentClientError> { + self.ensure_connected().await?; + + let resp = self + .http + .get(self.api_url("/torrents/info")) + .send() + .await?; + + let torrents: Vec = resp.json().await?; + Ok(torrents.into_iter().map(Self::map_torrent).collect()) + } + + async fn get_torrent(&self, hash: &str) -> Result { + self.ensure_connected().await?; + + let resp = self + .http + .get(self.api_url("/torrents/info")) + .query(&[("hashes", hash)]) + .send() + .await?; + + let torrents: Vec = 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(()) + } +}