diff --git a/Cargo.lock b/Cargo.lock index 28d318d..088bf35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -581,6 +602,7 @@ dependencies = [ "serde_repr", "serde_with", "tokio", + "tokio-test", ] [[package]] @@ -872,6 +894,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index 22cbdbc..b6d620c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,7 @@ serde_with = "1.14.0" serde_repr = "0.1" reqwest = { version = "0.11", features = ["cookies", "multipart"] } -tokio = { version = "1.19.2" } \ No newline at end of file + +[dev-dependencies] +tokio = { version = "1.19.2" } +tokio-test = "0.4.2" \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index ba55558..27159e0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,6 @@ -use crate::{error::ClientError, TorrentInfo, TorrentTracker, TorrentUpload}; +use serde_json::error::Category; + +use crate::{error::ClientError, torrent::{TorrentInfo, TorrentTracker, TorrentUpload}, common::*}; pub struct ConnectionInfo { pub url: String, @@ -25,6 +27,16 @@ impl QBittorrentClient { /// Login to qBittorrent. This must be ran so that the client can make requests. pub async fn login(&mut self, url: &str, username: &str, password: &str) -> ClientResult<()> { + // Remove trailing slash if necessary + let url = if url.ends_with("/") { + let mut chars = url.chars(); + chars.next_back(); + + chars.as_str() + } else { + url + }; + // Send response to get auth string let resp = self.client.post(format!("{}/api/v2/auth/login", url)) .form(&[ @@ -59,10 +71,23 @@ impl QBittorrentClient { } /// Get a list of all torrents in the client. - pub async fn get_torrent_list(&self) -> ClientResult> { + pub async fn get_torrent_list(&self, params: Option) -> ClientResult> { if let (Some(auth_string), Some(conn)) = (self.auth_string.as_ref(), self.connection_info.as_ref()) { + let mut url = format!("{}/api/v2/torrents/info", conn.url.clone()); + + if let Some(params) = params { + let mut params: &str = ¶ms.to_params(); + + // Remove leading & + if params.starts_with("&") { + params = ¶ms[1..params.len() - 1]; + } + + url.push_str(&format!("?{}", params)); + } + // Construct and send request to qbittorrent - let resp = self.client.post(format!("{}/api/v2/torrents/info", conn.url.clone())) + let resp = self.client.post(url) .header(reqwest::header::COOKIE, auth_string.clone()) .send().await?.error_for_status()?; @@ -115,6 +140,24 @@ impl QBittorrentClient { } } + /// Add multiple trackers to a torrent. + pub async fn add_torrent_trackers(&self, torrent: &TorrentInfo, trackers: Vec) -> ClientResult<()> { + if let (Some(auth_string), Some(conn)) = (self.auth_string.as_ref(), self.connection_info.as_ref()) { + // Construct and send request to qbittorrent + let _resp = self.client.post(format!("{}/api/v2/torrents/addTrackers", conn.url.clone())) + .header(reqwest::header::COOKIE, auth_string.clone()) + .form(&[ + ("hash", torrent.hash.clone()), + ("urls", trackers.join("\n")), + ]) + .send().await?.error_for_status()?; + + Ok(()) + } else { + Err(ClientError::Authorization) + } + } + /// Replace a tracker url on a torrent. pub async fn replace_torrent_tracker(&self, torrent: &TorrentInfo, old_url: String, new_url: String) -> ClientResult<()> { if let (Some(auth_string), Some(conn)) = (self.auth_string.as_ref(), self.connection_info.as_ref()) { diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..abf7887 --- /dev/null +++ b/src/common.rs @@ -0,0 +1,175 @@ +use serde_with::rust::seq_display_fromstr; + +/// This module contains common structs, and functions that can be used +/// by other crates. This is re-exported in `abstracttorrent` and used in it. + +#[derive(Debug, Clone)] +pub enum TorrentListFilter { + All, + Downloading, + Seeding, + Completed, + Paused, + Active, + Inactive, + Resumed, + Stalled, + StalledUploading, + StalledDownloading, + Errored, +} + +impl TorrentListFilter { + pub fn to_string(&self) -> &str { + match *self { + TorrentListFilter::All => "all", + TorrentListFilter::Downloading => "downloading", + TorrentListFilter::Seeding => "seeding", + TorrentListFilter::Completed => "completed", + TorrentListFilter::Paused => "paused", + TorrentListFilter::Active => "active", + TorrentListFilter::Inactive => "inactive", + TorrentListFilter::Resumed => "resumed", + TorrentListFilter::Stalled => "stalled", + TorrentListFilter::StalledUploading => "stalled_uploading", + TorrentListFilter::StalledDownloading => "stalled_downloading", + TorrentListFilter::Errored => "errored", + } + } +} + +#[derive(Default, Clone)] +pub struct GetTorrentListParams { + /// Filter torrent list by state + pub filter: Option, + + /// Get torrents with the given category + pub category: Option, + + /// Get torrents with the given tag. + pub tag: Option, + + // TODO: Add `sort` support for TorrentInfo fields. + + /// Enable reverse sorting. + pub reverse: Option, + + /// Limit the number of results. + pub limit: Option, + + /// Set offset. + pub offset: Option, + + /// Filter by hashes. + pub hashes: Option> // NOTE: Separated by `|` +} + +impl GetTorrentListParams { + pub fn builder() -> GetTorrentListParamsBuilder { + GetTorrentListParamsBuilder::default() + } + + pub fn to_params(&self) -> String { + let mut params = String::new(); + + if let Some(filter) = &self.filter { + params.push_str(&format!("&filter={}", filter.to_string())); + } + + if let Some(category) = &self.category { + params.push_str(&format!("&category={}", category)); + } + + if let Some(tag) = &self.tag { + params.push_str(&format!("&tag={}", tag)); + } + + if let Some(reverse) = &self.reverse { + params.push_str(&format!("&reverse={}", reverse.to_string())); + } + + if let Some(limit) = &self.limit { + params.push_str(&format!("&limit={}", limit)); + } + + if let Some(offset) = &self.limit { + params.push_str(&format!("&offset={}", offset)); + } + + if let Some(hashes) = &self.hashes { + let hashes = hashes.join("|"); + params.push_str(&format!("&hashes={}", hashes)); + } + + params + } +} + +#[derive(Default)] +pub struct GetTorrentListParamsBuilder { + param: GetTorrentListParams, +} + +impl GetTorrentListParamsBuilder { + /// Set a filter + pub fn filter(&mut self, filter: TorrentListFilter) -> &mut Self { + self.param.filter = Some(filter); + + self + } + + /// Set a filter. + pub fn category(&mut self, category: &str) -> &mut Self { + self.param.category = Some(category.to_string()); + + self + } + + /// Set a tag. + pub fn tag(&mut self, tag: &str) -> &mut Self { + self.param.tag = Some(tag.to_string()); + + self + } + + /// Reverse the order of the results. + pub fn reverse(&mut self) -> &mut Self { + self.param.reverse = Some(true); + + self + } + + /// Set a limit on the number of results returned. + pub fn limit(&mut self, limit: i32) -> &mut Self { + self.param.limit = Some(limit); + + self + } + + /// Set an offset of the results. + pub fn offset(&mut self, offset: i32) -> &mut Self { + self.param.offset = Some(offset); + + self + } + + /// Add a hash to filter by. + pub fn hash(&mut self, hash: &str) -> &mut Self { + self.param.hashes.as_mut() + .unwrap_or(&mut vec![]) + .push(hash.to_string()); + + self + } + + /// Set the hashes to filter by. + pub fn hashes(&mut self, hashes: Vec) -> &mut Self { + self.param.hashes = Some(hashes); + + self + } + + pub fn build(&self) -> GetTorrentListParams { + self.param.clone() + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 035b2f2..da3f610 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,22 @@ pub mod torrent; -pub use torrent::*; - pub mod client; -pub use client::*; - pub mod error; -pub use error::*; \ No newline at end of file +pub mod common; + +#[cfg(test)] +mod tests { + macro_rules! block_on { + ($e:expr) => { + tokio_test::block_on($e) + }; + } + + #[test] + fn test_login() { + let mut client = super::client::QBittorrentClient::new(); + + block_on!(client.login("http://localhost:8080", "admin", "adminadmin")).unwrap(); + + println!("Logged in!"); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f0b664f..0000000 --- a/src/main.rs +++ /dev/null @@ -1,23 +0,0 @@ -/// NOTE: USED FOR TESTING - -pub mod torrent; -pub use torrent::*; - -pub mod client; -pub use client::*; - -pub mod error; -pub use error::*; - -#[tokio::main] -async fn main() { - let mut client = QBittorrentClient::new(); - - client.login( - String::from("http://localhost:8080"), - String::from("admin"), - String::from("adminadmin") - ).await.unwrap(); - - println!("Hello, world!"); -} diff --git a/src/torrent.rs b/src/torrent.rs index 55fa3b5..099c8df 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -145,7 +145,7 @@ pub struct TorrentInfo { } /// An enum representing the state of a torrent in the client. -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] pub enum TorrentState { /// Some error occurred, applies to paused torrents #[serde(rename = "error")]