From 0d9e287fc93a293bdd3367eba7552c420b23023c Mon Sep 17 00:00:00 2001 From: SeanOMik Date: Sun, 19 Jun 2022 13:54:09 -0400 Subject: [PATCH] Implement add torrent endpoint --- Cargo.lock | 95 ++++++++++++++++- Cargo.toml | 6 +- src/client.rs | 20 +++- src/main.rs | 11 +- src/torrent.rs | 284 ++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 395 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a64bc8..7f89bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,8 +99,18 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02" +dependencies = [ + "darling_core 0.14.1", + "darling_macro 0.14.1", ] [[package]] @@ -117,17 +127,73 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", "quote", "syn", ] +[[package]] +name = "darling_macro" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5" +dependencies = [ + "darling_core 0.14.1", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07adf7be193b71cc36b193d0f5fe60b918a3a9db4dad0449f57bcfd519704a3" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f91d4cfa921f1c05904dc3c57b4a32c38aed3340cce209f3a6fd1478babafc4" +dependencies = [ + "darling 0.14.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0314b72bed045f3a68671b3c86328386762c93f82d98c65c3cb5e5f573dd68" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "encoding_rs" version = "0.8.31" @@ -433,6 +499,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "0.8.3" @@ -617,6 +693,7 @@ dependencies = [ name = "qbittorrent" version = "0.1.0" dependencies = [ + "derive_builder", "reqwest", "serde", "serde_json", @@ -675,6 +752,7 @@ dependencies = [ "lazy_static", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -807,7 +885,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro2", "quote", "syn", @@ -995,6 +1073,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index 43ee129..2b33589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,5 +11,7 @@ serde_json = "1.0.81" serde_with = "1.14.0" serde_repr = "0.1" -reqwest = { version = "0.11.11", features = ["cookies"] } -tokio = { version = "1.19.2", features = ["full"] } \ No newline at end of file +reqwest = { version = "0.11.11", features = ["cookies", "multipart"] } +tokio = { version = "1.19.2", features = ["full"] } + +derive_builder = "0.11.2" \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index a4c9f99..542073a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -use crate::{error::ClientError, TorrentInfo, TorrentTracker}; +use crate::{error::ClientError, TorrentInfo, TorrentTracker, TorrentUpload}; pub struct ConnectionInfo { pub url: String, @@ -61,8 +61,6 @@ impl QBittorrentClient { /// Get a list of all torrents in the client. pub async fn get_torrent_list(&self) -> ClientResult> { if let (Some(auth_string), Some(conn)) = (self.auth_string.as_ref(), self.connection_info.as_ref()) { - println!("Authentication string: {}", auth_string); - // Construct and send request to qbittorrent let resp = self.client.post(format!("{}/api/v2/torrents/info", conn.url.clone())) .header(reqwest::header::COOKIE, auth_string.clone()) @@ -81,8 +79,6 @@ impl QBittorrentClient { /// Get a list of trackers for a torrent. pub async fn get_torrent_trackers(&self, torrent: &TorrentInfo) -> ClientResult> { if let (Some(auth_string), Some(conn)) = (self.auth_string.as_ref(), self.connection_info.as_ref()) { - println!("Authentication string: {}", auth_string); - // Construct and send request to qbittorrent let resp = self.client.post(format!("{}/api/v2/torrents/trackers", conn.url.clone())) .header(reqwest::header::COOKIE, auth_string.clone()) @@ -100,4 +96,18 @@ impl QBittorrentClient { Err(ClientError::Authorization) } } + + pub async fn add_torrent(&self, upload: &TorrentUpload) -> 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/add", conn.url.clone())) + .header(reqwest::header::COOKIE, auth_string.clone()) + .multipart(upload.to_multipart_form()) + .send().await?.error_for_status()?; + + Ok(()) + } else { + Err(ClientError::Authorization) + } + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 864c8e4..11ef131 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ +#[macro_use] +extern crate derive_builder; + pub mod torrent; +use std::path::Path; + pub use torrent::*; pub mod client; @@ -17,11 +22,5 @@ async fn main() { String::from("adminadmin") ).await.unwrap(); - let torrents = client.get_torrent_list().await.unwrap(); - - let first = torrents.first().unwrap(); - - client.get_torrent_trackers(first).await.unwrap(); - println!("Hello, world!"); } diff --git a/src/torrent.rs b/src/torrent.rs index 09404cc..18a4fd7 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -42,7 +42,7 @@ pub struct TorrentInfo { pub downloaded_session: u64, /// Torrent ETA (seconds) - pub eta: u64, + pub eta: i64, /// True if first last piece are prioritized pub f_l_piece_prio: bool, @@ -54,7 +54,7 @@ pub struct TorrentInfo { pub hash: String, /// Last time (Unix Epoch) when a chunk was downloaded/uploaded - pub last_activity: u64, + pub last_activity: i64, /// Magnet URI corresponding to this torrent pub magnet_uri: String, @@ -110,7 +110,7 @@ pub struct TorrentInfo { pub seq_dl: bool, /// Total size (bytes) of files selected for download - pub size: u64, + pub size: i64, /// Torrent state. See table here below for the possible values pub state: TorrentState, @@ -126,7 +126,7 @@ pub struct TorrentInfo { pub time_active: i32, /// Total size (bytes) of all file in this torrent (including unselected ones) - pub total_size: u64, + pub total_size: i64, /// The first tracker with working status. Returns empty string if no tracker is working. pub tracker: String, @@ -271,4 +271,280 @@ pub enum TrackerStatus { /// Tracker has been contacted, but it is not working (or doesn't send proper replies) NotWorking = 4 +} + +/// Represents a request to add torrents to the client. +#[derive(Debug, Default/* , Serialize, Deserialize */)] +pub struct TorrentUpload { + /// URL(s) of the torrent files. When specifying `http` or `https` URLs, they + /// don't always get downloaded by qbittorrent. The best way to verify if it was added + /// to the client is to check the torrent list after the request. + urls: Vec, // NOTE: Separated by new lines + + /// Binary data of the torrents that are being added. + /// Torrent file data that is being added. (Name, Bytes) + torrents: Vec<(String, Vec)>, + + /// Download folder + save_path: Option, // NOTE: Rename to `savepath` for (de)serialization + + /// Cookie sent to download the .torrent file + cookie: Option, + + /// Category for the torrent + category: Option, + + /// Tags for the torrent + tags: Option>, // NOTE: Split by commas + + /// Skip hash checking. + skip_hash_check: Option, // NOTE: Convert to string and rename to `skip_hash_check` for (de)serialization + + /// Add torrents in the paused state. + paused: Option, + + /// Create the root folder. + root_folder: Option, // NOTE: Convert to string for (de)serialization + + /// Rename torrent + rename: Option, + + /// Set torrent upload speed limit. Unit in bytes/second + upload_limit: Option, // NOTE: Rename to `upLimit` for (de)serialization + + /// Set torrent download speed limit. Unit in bytes/second + download_limit: Option, // NOTE: Rename to `upLimit` for (de)serialization + + /// Set torrent share ratio limit + ratio_limit: Option, // NOTE: Rename to `ratioLimit` for (de)serialization + + /// Set torrent seeding time limit. Unit in seconds + seeding_time_limit: Option, // NOTE: Rename to `seedingTimeLimit` for (de)serialization + + /// Whether Automatic Torrent Management should be used + auto_tmm: Option, // NOTE: Rename to `autoTMM` for (de)serialization + + /// Enable sequential download. Possible values are true, false (default) + sequential_download: Option, // NOTE: Rename to `sequentialDownload` and convert to string for (de)serialization + + /// Prioritize download first last piece. Possible values are true, false (default) + first_last_piece_prio: Option, // NOTE: Rename to `firstLastPiecePrio` and convert to string for (de)serialization +} + +#[derive(Debug, Default)] +pub struct TorrentUploadBuilder { + params: TorrentUpload, +} + +impl TorrentUploadBuilder { + pub fn url(&mut self, url: String) -> &mut Self { + self.params.urls.push(url); + self + } + + pub fn torrent_file(&mut self, torrent_path: String) -> &mut Self { + let path = std::path::Path::new(&torrent_path); + + self.torrent_path(path) + } + + pub fn torrent_path(&mut self, torrent_path: &std::path::Path) -> &mut Self { + let torrents = &mut self.params.torrents; + torrents.push(( + torrent_path.file_name().unwrap().to_str().unwrap().to_string(), + std::fs::read(torrent_path).unwrap(), + )); + + self + } + + pub fn torrent_data(&mut self, filename: String, data: Vec) -> &mut Self { + let torrents = &mut self.params.torrents; + torrents.push(( + filename, + data, + )); + + self + } + + pub fn save_path(&mut self, save_path: String) -> &mut Self { + self.params.save_path = Some(save_path); + self + } + + pub fn cookie(&mut self, cookie: String) -> &mut Self { + self.params.cookie = Some(cookie); + self + } + + pub fn category(&mut self, category: String) -> &mut Self { + self.params.category = Some(category); + self + } + + pub fn tag(&mut self, tag: String) -> &mut Self { + self.params.tags.as_mut().unwrap_or(&mut vec![]).push(tag); + self + } + + pub fn tags(&mut self, tags: Vec) -> &mut Self { + self.params.tags = Some(tags); + self + } + + pub fn skip_hash_check(&mut self, skip_hash_check: bool) -> &mut Self { + self.params.skip_hash_check = Some(skip_hash_check); + self + } + + pub fn paused(&mut self, paused: bool) -> &mut Self { + self.params.paused = Some(paused); + self + } + + pub fn root_folder(&mut self, root_folder: bool) -> &mut Self { + self.params.root_folder = Some(root_folder); + self + } + + pub fn rename(&mut self, rename: String) -> &mut Self { + self.params.rename = Some(rename); + self + } + + pub fn upload_limit(&mut self, upload_limit: i64) -> &mut Self { + self.params.upload_limit = Some(upload_limit); + self + } + + pub fn download_limit(&mut self, download_limit: i64) -> &mut Self { + self.params.download_limit = Some(download_limit); + self + } + + pub fn ratio_limit(&mut self, ratio_limit: f32) -> &mut Self { + self.params.ratio_limit = Some(ratio_limit); + self + } + + pub fn seeding_time_limit(&mut self, seeding_time_limit: u64) -> &mut Self { + self.params.seeding_time_limit = Some(seeding_time_limit); + self + } + + pub fn auto_tmm(&mut self, auto_tmm: bool) -> &mut Self { + self.params.auto_tmm = Some(auto_tmm); + self + } + + pub fn sequential_download(&mut self, sequential_download: bool) -> &mut Self { + self.params.sequential_download = Some(sequential_download); + self + } + + pub fn first_last_piece_prio(&mut self, first_last_piece_prio: bool) -> &mut Self { + self.params.first_last_piece_prio = Some(first_last_piece_prio); + self + } + + pub fn build(&self) -> &TorrentUpload { + &self.params + } +} + +impl TorrentUpload { + /// Get a builder of `TorrentUpload` + pub fn builder() -> TorrentUploadBuilder { + TorrentUploadBuilder::default() + } + + // TODO: Add result for when neither `urls` and `torrents` are not set. For now it just panics. + pub fn to_multipart_form(&self) -> reqwest::multipart::Form { + if self.urls.is_empty() && self.torrents.is_empty() { + panic!("Either `urls` or `torrents` must be set!!"); + } + + let mut form = reqwest::multipart::Form::new(); + + // Add urls separated by new lines + if !self.urls.is_empty() { + let urls = self.urls.join("\n"); + + form = form.text("urls", urls); // For some reason I have to do this :( + } + + // Add the torrents as files + if !self.torrents.is_empty() { + for torrent in self.torrents.iter() { + // TODO: Avoid a clone here? + form = form.part("torrents", reqwest::multipart::Part::bytes(torrent.1.clone()) + .file_name(torrent.0.clone()) + .mime_str("application/x-bittorrent").unwrap()); + } + } + + if let Some(save_path) = &self.save_path { + form = form.text("savepath", save_path.to_owned()); + } + + if let Some(cookie) = &self.cookie { + form = form.text("cookie", cookie.to_owned()); + } + + if let Some(category) = &self.category { + form = form.text("category", category.to_owned()); + } + + if let Some(tags) = &self.tags { + let tags = tags.join(","); + form = form.text("tags", tags); + } + + if let Some(skip_hash_check) = &self.skip_hash_check { + form = form.text("skip_checking", skip_hash_check.to_string()); + } + + if let Some(paused) = &self.paused { + form = form.text("paused", paused.to_string()); + } + + if let Some(root_folder) = &self.root_folder { + form = form.text("root_folder", root_folder.to_string()); + } + + if let Some(rename) = &self.rename { + form = form.text("rename", rename.to_owned()); + } + + if let Some(upload_limit) = &self.upload_limit { + form = form.text("upLimit", upload_limit.to_string()); + } + + if let Some(download_limit) = &self.download_limit { + form = form.text("dlLimit", download_limit.to_string()); + } + + if let Some(ratio_limit) = &self.ratio_limit { + form = form.text("ratioLimit", ratio_limit.to_string()); + } + + if let Some(seeding_time_limit) = &self.seeding_time_limit { + form = form.text("seedingTimeLimit", seeding_time_limit.to_string()); + } + + if let Some(auto_tmm) = &self.auto_tmm { + form = form.text("autoTMM", auto_tmm.to_string()); + } + + if let Some(sequential_download) = &self.sequential_download { + form = form.text("sequentialDownload", sequential_download.to_string()); + } + + if let Some(first_last_piece_prio) = &self.first_last_piece_prio { + form = form.text("firstLastPiecePrio", first_last_piece_prio.to_string()); + } + + form + } } \ No newline at end of file