From c6d9a0c07dae301e64771ece92dc37799d616283 Mon Sep 17 00:00:00 2001 From: "programmer@kl.netlib.re" Date: Thu, 3 Nov 2022 15:14:50 +0100 Subject: [PATCH] Add replace-tracker/replace-all-trackers commands API supports get/add/remove_tracker method Workaround for qbittorrent API dont return name/hash of torrent on "info" method Patch qbittorrent_web_api API spec to support add_trackers arguments --- .gitmodules | 3 + Cargo.lock | 147 ++++++++++++++++++++++++++++- Cargo.toml | 4 +- src/action/get.rs | 9 +- src/action/get_trackers.rs | 35 +++++++ src/action/list.rs | 9 +- src/action/mod.rs | 19 +++- src/action/replace_all_trackers.rs | 74 +++++++++++++++ src/action/replace_tracker.rs | 85 +++++++++++++++++ src/api/list.rs | 39 ++++++++ src/api/mod.rs | 62 ++---------- src/api/qbittorrent/mod.rs | 97 ++++++++++++++++++- src/api/qbittorrent/types.rs | 18 +--- src/api/target.rs | 33 +++++++ src/api/torrent.rs | 15 +++ src/api/tracker.rs | 13 +++ src/error.rs | 2 + vendor/qbittorrent_web_api | 1 + 18 files changed, 582 insertions(+), 83 deletions(-) create mode 100644 src/action/get_trackers.rs create mode 100644 src/action/replace_all_trackers.rs create mode 100644 src/action/replace_tracker.rs create mode 100644 src/api/list.rs create mode 100644 src/api/target.rs create mode 100644 src/api/torrent.rs create mode 100644 src/api/tracker.rs create mode 160000 vendor/qbittorrent_web_api diff --git a/.gitmodules b/.gitmodules index e90cbe8..418253f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "vendor/intermodal"] path = vendor/intermodal url = https://kl.netlib.re/gitea/programmer/intermodal +[submodule "vendor/qbittorrent_web_api"] + path = vendor/qbittorrent_web_api + url = https://kl.netlib.re/gitea/programmer/qbittorrent_web_api diff --git a/Cargo.lock b/Cargo.lock index 514cc94..c3ca7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,6 +418,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-iterator" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91a4ec26efacf4aeff80887a175a419493cb6f8b5480d26387eb0bd038976187" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "828de45d0ca18782232dfb8f3ea9cc428e8ced380eb26a520baaacfc70de39ce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_logger" version = "0.7.1" @@ -683,6 +703,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1194,8 +1227,7 @@ dependencies = [ [[package]] name = "qbittorrent-web-api" version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d66c1ef8101cf57f7ca3925ea8306a0e386a4edb6e4d1cfd33d4f92271821b" +source = "git+https://kl.netlib.re/gitea/programmer/qbittorrent_web_api#a0c3fb9d5d86fccc806c95c56361c61c0b62f30a" dependencies = [ "qbittorrent-web-api-gen", "reqwest", @@ -1209,8 +1241,7 @@ dependencies = [ [[package]] name = "qbittorrent-web-api-gen" version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aec126f1ff4af4a2cb0f13af3bbe69f675697b77573c23223f11fa41af93296a" +source = "git+https://kl.netlib.re/gitea/programmer/qbittorrent_web_api#a0c3fb9d5d86fccc806c95c56361c61c0b62f30a" dependencies = [ "case", "proc-macro2", @@ -1230,11 +1261,13 @@ dependencies = [ "argh", "imdl", "qbittorrent-web-api", + "regex", "serde", "serde_json", "snafu 0.7.2", "tokio", "toml", + "transmission-rpc", "xdg", ] @@ -1314,6 +1347,7 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -1324,25 +1358,65 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rustc-demangle" version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" +[[package]] +name = "rustls" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539a2bfe908f471bfa933876bd1eb6a19cf2176d375f82ef7f99530a40e48c2c" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55" +dependencies = [ + "base64", +] + [[package]] name = "ryu" version = "1.0.11" @@ -1380,6 +1454,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.7.0" @@ -1600,6 +1684,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1841,6 +1931,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + [[package]] name = "tokio-util" version = "0.7.4" @@ -1890,6 +1991,19 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transmission-rpc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180df4216995b3aeb948445d7c297fa8909ab3042e679cd8054251560e651b3b" +dependencies = [ + "enum-iterator", + "log", + "reqwest", + "serde", + "serde_repr", +] + [[package]] name = "try-lock" version = "0.2.3" @@ -1944,6 +2058,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.3.1" @@ -2083,6 +2203,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368bfe657969fb01238bb756d351dcade285e0f6fcbd36dcb23359a5169975be" +dependencies = [ + "webpki", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index f158c4d..6eb7ff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,12 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1" xdg = "2.4" snafu = "0.7" -qbittorrent-web-api = "0.6" +qbittorrent-web-api = { git = "https://kl.netlib.re/gitea/programmer/qbittorrent_web_api" } argh = "0.1" tokio = "1.21" imdl = { path = "vendor/intermodal" } +regex = "1" +transmission-rpc = "0.4" [profile.release] strip = true diff --git a/src/action/get.rs b/src/action/get.rs index 2314a4f..f8a5b50 100644 --- a/src/action/get.rs +++ b/src/action/get.rs @@ -15,6 +15,9 @@ pub struct GetAction { #[argh(switch, short = 'r')] /// return raw JSON response from API raw: bool, + #[argh(switch, short = 'j')] + /// return parsed JSON response from API + json: bool, #[argh(positional)] /// the infohash (or magnet link with --magnet) to retrieve torrent: String, @@ -29,7 +32,11 @@ impl ActionExec for GetAction { } else { let api = ApiClient::from_config(&config)?; if let Some(t) = api.get(&self.torrent)? { - println!("{}", t.hash); + if self.json { + println!("{}", &serde_json::to_string(&t).unwrap()); + } else { + println!("{}", t.hash); + } } } diff --git a/src/action/get_trackers.rs b/src/action/get_trackers.rs new file mode 100644 index 0000000..bd7be40 --- /dev/null +++ b/src/action/get_trackers.rs @@ -0,0 +1,35 @@ +use argh::FromArgs; + +use crate::action::ActionExec; +use crate::api::qbittorrent::ApiClient; +use crate::config::Config; +use crate::error::Error; + +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "get-trackers")] +/// Get the list of trackers on a torrent +/// TODO: Also make list of all trackers? +pub struct GetTrackersAction { + #[argh(switch, short = 'j')] + /// return the list of trackers as a JSON object + json: bool, + #[argh(positional)] + /// the torrent to fetch trackers from + torrent: String, +} + +impl ActionExec for GetTrackersAction { + fn exec(&self, config: &Config) -> Result<(), Error> { + let api = ApiClient::from_config(&config)?; + let trackers = api.get_trackers(&self.torrent)?; + + if self.json { + println!("{}", serde_json::to_string(&trackers).unwrap()); + } else { + for entry in &trackers { + println!("{}", &entry.url); + } + } + Ok(()) + } +} diff --git a/src/action/list.rs b/src/action/list.rs index 2c3ad70..129bffb 100644 --- a/src/action/list.rs +++ b/src/action/list.rs @@ -12,6 +12,9 @@ pub struct ListAction { #[argh(switch, short = 'r')] /// return raw JSON response from API raw: bool, + #[argh(switch, short = 'j')] + /// return parsed JSON response from API + json: bool, } impl ActionExec for ListAction { @@ -22,7 +25,11 @@ impl ActionExec for ListAction { } else { let api = ApiClient::from_config(&config)?; for torrent in api.list()? { - println!("{}", torrent.hash); + if self.json { + println!("{}", &serde_json::to_string(&torrent).unwrap()); + } else { + println!("{}", torrent.hash); + } } } Ok(()) diff --git a/src/action/mod.rs b/src/action/mod.rs index d935e53..079c669 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -7,6 +7,8 @@ pub mod add; pub use add::AddAction; pub mod get; pub use get::GetAction; +pub mod get_trackers; +pub use get_trackers::GetTrackersAction; pub mod hash; pub use hash::HashAction; pub mod list; @@ -15,16 +17,23 @@ pub mod magnet; pub use magnet::MagnetAction; pub mod name; pub use name::NameAction; +pub mod replace_tracker; +pub use replace_tracker::ReplaceTrackerAction; +pub mod replace_all_trackers; +pub use replace_all_trackers::ReplaceAllTrackersAction; -#[derive(FromArgs, PartialEq, Debug)] +#[derive(FromArgs, Debug)] #[argh(subcommand)] pub enum Action { Add(AddAction), List(ListAction), Get(GetAction), + GetTrackers(GetTrackersAction), Name(NameAction), Hash(HashAction), Magnet(MagnetAction), + ReplaceTracker(ReplaceTrackerAction), + ReplaceAllTrackers(ReplaceAllTrackersAction), } pub trait ActionExec { @@ -40,13 +49,19 @@ impl Action { cmd.exec(&config) }, Action::Get(cmd) => { cmd.exec(&config) + }, Action::GetTrackers(cmd) => { + cmd.exec(&config) }, Action::Hash(cmd) => { cmd.exec(&config) }, Action::Name(cmd) => { cmd.exec(&config) }, Action::Magnet(cmd) => { cmd.exec(&config) - } + }, Action::ReplaceTracker(cmd) => { + cmd.exec(&config) + }, Action::ReplaceAllTrackers(cmd) => { + cmd.exec(&config) + }, } } diff --git a/src/action/replace_all_trackers.rs b/src/action/replace_all_trackers.rs new file mode 100644 index 0000000..fa31d55 --- /dev/null +++ b/src/action/replace_all_trackers.rs @@ -0,0 +1,74 @@ +use argh::FromArgs; + +use crate::action::ActionExec; +use crate::api::{TorrentTarget, qbittorrent::ApiClient}; +use crate::config::Config; +use crate::error::Error; + +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "replace-all-trackers")] +/// replace all trackers on a torrent or "all" torrents, with the list of trackers given here +pub struct ReplaceAllTrackersAction { + #[argh(positional)] + /// the infohash for which to replace a tracker ; use "all" to replace for all torrents + torrent: TorrentTarget, + #[argh(positional, greedy)] + /// the list of new trackers to set + new_trackers: Vec, +} + +impl ActionExec for ReplaceAllTrackersAction { + fn exec(&self, config: &Config) -> Result<(), Error> { + let api = ApiClient::from_config(&config)?; + let mut errors: Vec = Vec::new(); + + println!("Adding new trackers: {}", &self.new_trackers.join(",")); + + // Workaround for weird qBittorrent API + let list = api.list()?; + + for entry in self.torrent.to_vec(&api)? { + match api.get_with_cached_list(&entry, &list) { + Ok(found_torrent) => { + let found_torrent = found_torrent.unwrap_or_else(|| panic!("Torrent in 'list' could not be 'get': {}", &entry)); + match api.get_trackers(&found_torrent.hash) { + Ok(found_trackers) => { + for found_tracker in found_trackers { + let found_tracker = found_tracker.url; + match api.remove_tracker(&found_torrent.hash, &found_tracker) { + Ok(_) => {}, + Err(e) => { + println!("Failed to remove tracker {} for {}", &found_tracker, &found_torrent.hash); + errors.push(e); + }, + } + } + + for new_tracker in &self.new_trackers { + match api.add_tracker(&found_torrent.hash, new_tracker) { + Ok(()) => {}, + Err(e) => { + println!("Failed to add tracker {} for {}", &new_tracker, &found_torrent.hash); + errors.push(e); + } + } + } + }, Err(e) => { + println!("Failed to find trackers for torrent {}", &found_torrent.hash); + errors.push(e); + } + } + }, Err(e) => { + println!("Failed to find info about torrent {}", entry); + errors.push(e); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(Error::MultipleErrors { sources: errors }) + } + } +} diff --git a/src/action/replace_tracker.rs b/src/action/replace_tracker.rs new file mode 100644 index 0000000..b92bd9d --- /dev/null +++ b/src/action/replace_tracker.rs @@ -0,0 +1,85 @@ +use argh::FromArgs; + +use crate::action::ActionExec; +use crate::api::{TorrentTarget, qbittorrent::ApiClient}; +use crate::config::Config; +use crate::error::Error; + +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "replace-tracker")] +/// replace trackers on a torrent which match a specific regex ; the new trackers are only +/// added if a match in older trackers was found +pub struct ReplaceTrackerAction { + #[argh(positional)] + /// the infohash for which to replace a tracker ; use "all" to replace for all torrents + torrent: TorrentTarget, + #[argh(positional)] + /// a REGEX matching the tracker(s) to be replaced + old_tracker: regex::Regex, + #[argh(positional, greedy)] + /// the list of new trackers to set + new_trackers: Vec, +} + +impl ActionExec for ReplaceTrackerAction { + fn exec(&self, config: &Config) -> Result<(), Error> { + let api = ApiClient::from_config(&config)?; + let mut errors: Vec = Vec::new(); + + // Workaround for weird qBittorrent API + let list = api.list()?; + + for entry in self.torrent.to_vec(&api)? { + match api.get_with_cached_list(&entry, &list) { + Ok(found_torrent) => { + let found_torrent = found_torrent.unwrap_or_else(|| panic!("Torrent in 'list' could not be 'get': {}", &entry)); + match api.get_trackers(&found_torrent.hash) { + Ok(found_trackers) => { + let mut found_matching_tracker = false; + + for found_tracker in found_trackers { + let found_tracker = found_tracker.url; + if self.old_tracker.is_match(&found_tracker) { + found_matching_tracker = true; + + match api.remove_tracker(&found_torrent.hash, &found_tracker) { + Ok(_) => {}, + Err(e) => { + println!("Failed to remove tracker {} for {}", &found_tracker, &found_torrent.hash); + errors.push(e); + }, + } + } + } + + // add new trackers if there was a matching tracker found + if found_matching_tracker { + for new_tracker in &self.new_trackers { + match api.add_tracker(&found_torrent.hash, new_tracker) { + Ok(()) => {}, + Err(e) => { + println!("Failed to add tracker {} for {}", &new_tracker, &found_torrent.hash); + errors.push(e); + } + } + } + } + }, Err(e) => { + println!("Failed to find trackers for torrent {}", &found_torrent.hash); + errors.push(e); + } + } + }, Err(e) => { + println!("Failed to find info about torrent {}", entry); + errors.push(e); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(Error::MultipleErrors { sources: errors }) + } + } +} diff --git a/src/api/list.rs b/src/api/list.rs new file mode 100644 index 0000000..0c1683b --- /dev/null +++ b/src/api/list.rs @@ -0,0 +1,39 @@ +pub use crate::api::Torrent; + +#[derive(Clone, Serialize, Deserialize)] +pub struct TorrentList(Vec); + +impl TorrentList { + pub fn new() -> TorrentList { + TorrentList(Vec::new()) + } + + pub fn push(&mut self, entry: Torrent) { + self.0.push(entry); + } + + pub fn to_vec(self) -> Vec { + self.0 + } +} + +impl IntoIterator for TorrentList { + type Item = Torrent; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl FromIterator for TorrentList { + fn from_iter>(iter: I) -> Self { + let mut c = TorrentList::new(); + + for i in iter { + c.push(i); + } + + c + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index d18ad00..dfe8894 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,56 +1,10 @@ pub mod qbittorrent; -pub fn calc_progress(have: f64, total: f64) -> u8 { - let ratio = have / total * f64::from(100); - ratio.floor() as u8 -} - -pub trait IntoTorrent { - fn into_torrent(&self, hash: &str) -> Torrent; -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct Torrent { - pub hash: String, - pub name: String, - pub path: String, - pub date_start: i64, - pub date_end: i64, - /// Progress percentage (0-100) - pub progress: u8, - pub size: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct TorrentList(Vec); - -impl TorrentList { - pub fn new() -> TorrentList { - TorrentList(Vec::new()) - } - - pub fn push(&mut self, entry: Torrent) { - self.0.push(entry); - } -} - -impl IntoIterator for TorrentList { - type Item = Torrent; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -impl FromIterator for TorrentList { - fn from_iter>(iter: I) -> Self { - let mut c = TorrentList::new(); - - for i in iter { - c.push(i); - } - - c - } -} +pub mod torrent; +pub use torrent::{Torrent, IntoTorrent}; +pub mod list; +pub use list::TorrentList; +pub mod target; +pub use target::TorrentTarget; +pub mod tracker; +pub use tracker::TorrentTracker; diff --git a/src/api/qbittorrent/mod.rs b/src/api/qbittorrent/mod.rs index 844f581..9e8c65c 100644 --- a/src/api/qbittorrent/mod.rs +++ b/src/api/qbittorrent/mod.rs @@ -8,7 +8,7 @@ use crate::error::{Error, ApiSnafu as IOError, InternalApiSnafu as ApiError, Fai mod types; pub use types::QBittorrentPropertiesTorrent; pub use types::QBittorrentListTorrent; -use crate::api::{Torrent, TorrentList, IntoTorrent}; +use crate::api::{Torrent, TorrentList, IntoTorrent, TorrentTracker}; pub fn blocking_runtime() -> std::io::Result { Builder::new_current_thread() @@ -51,6 +51,21 @@ impl RawAsyncApiClient { pub async fn list(&self) -> Result { self.api.torrent_management().info().send_raw().await.context(ApiError) } + + #[allow(dead_code)] + pub async fn add_tracker(&self, hash: &str, url: &str) -> Result { + self.api.torrent_management().add_trackers_raw(hash, url).await.context(ApiError) + } + + #[allow(dead_code)] + pub async fn get_trackers(&self, hash: &str) -> Result { + self.api.torrent_management().trackers_raw(hash).await.context(ApiError) + } + + #[allow(dead_code)] + pub async fn remove_tracker(&self, hash: &str, url: &str) -> Result { + self.api.torrent_management().remove_trackers_raw(hash, &vec!(url)).await.context(ApiError) + } } #[derive(Debug)] @@ -89,6 +104,21 @@ impl RawApiClient { pub fn list(&self) -> Result { self.rt.block_on(self.api.list()) } + + #[allow(dead_code)] + pub fn add_tracker(&self, hash: &str, url: &str) -> Result { + self.rt.block_on(self.api.add_tracker(hash, url)) + } + + #[allow(dead_code)] + pub fn get_trackers(&self, hash: &str) -> Result { + self.rt.block_on(self.api.get_trackers(hash)) + } + + #[allow(dead_code)] + pub fn remove_tracker(&self, hash: &str, url: &str) -> Result { + self.rt.block_on(self.api.remove_tracker(hash, url)) + } } #[derive(Debug)] @@ -127,8 +157,27 @@ impl AsyncApiClient { if res == "" { Ok(None) } else { - let concrete: QBittorrentPropertiesTorrent = serde_json::from_str(&res).context(DeserializeError)?; - Ok(Some(concrete.into_torrent(hash))) + // TODO: NOT OPTIMIZED AT ALL. API DOES NOT RETURN NAME/HASH OF TORRENT SO WE HAVE TO QUERY LIST... + let list = self.list().await?; + let filtered_list = list.into_iter().filter(|t| t.hash == hash).collect::>(); + let torrent = filtered_list.first() + .unwrap_or_else(|| panic!("Torrent was 'get' but could not be found in 'list': {}", &hash)); + Ok(Some(torrent.clone())) + } + } + + #[allow(dead_code)] + /// This method only exists because qBittorrent API 'properties' endpoint does not return torrent name/hash + /// so if you need to do a lot of 'get', please call 'list' once and use this method instead. + pub async fn get_with_cached_list(&self, hash: &str, list: &TorrentList) -> Result, Error> { + let res = self.raw_api.get(hash).await?; + if res == "" { + Ok(None) + } else { + let filtered_list = list.clone().into_iter().filter(|t| t.hash == hash).collect::>(); + let torrent = filtered_list.first() + .unwrap_or_else(|| panic!("Torrent was 'get' but could not be found in 'list': {}", &hash)); + Ok(Some(torrent.clone())) } } @@ -136,9 +185,28 @@ impl AsyncApiClient { pub async fn list(&self) -> Result { let res = self.raw_api.list().await?; let concrete: Vec = serde_json::from_str(&res).context(DeserializeError)?; - Ok(concrete.iter().map(|t| t.into_torrent(&t.hash)).collect()) + Ok(concrete.iter().map(|t| t.into_torrent()).collect()) } + #[allow(dead_code)] + pub async fn add_tracker(&self, hash: &str, url: &str) -> Result<(), Error> { + let _ = self.raw_api.add_tracker(hash, url).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn get_trackers(&self, hash: &str) -> Result, Error> { + let res = self.raw_api.get_trackers(hash).await?; + let concrete: Vec = serde_json::from_str(&res).context(DeserializeError)?; + let concrete_filter_dht = concrete.into_iter().filter(|t| t.is_tracker()).collect(); + Ok(concrete_filter_dht) + } + + #[allow(dead_code)] + pub async fn remove_tracker(&self, hash: &str, url: &str) -> Result<(), Error> { + let _ = self.raw_api.remove_tracker(hash, url).await?; + Ok(()) + } } #[derive(Debug)] @@ -174,8 +242,29 @@ impl ApiClient { self.rt.block_on(self.api.get(hash)) } + #[allow(dead_code)] + // TODO: Workaround + pub fn get_with_cached_list(&self, hash: &str, list: &TorrentList) -> Result, Error> { + self.rt.block_on(self.api.get_with_cached_list(hash, list)) + } + #[allow(dead_code)] pub fn list(&self) -> Result { self.rt.block_on(self.api.list()) } + + #[allow(dead_code)] + pub fn add_tracker(&self, hash: &str, url: &str) -> Result<(), Error> { + self.rt.block_on(self.api.add_tracker(hash, url)) + } + + #[allow(dead_code)] + pub fn get_trackers(&self, hash: &str) -> Result, Error> { + self.rt.block_on(self.api.get_trackers(hash)) + } + + #[allow(dead_code)] + pub fn remove_tracker(&self, hash: &str, url: &str) -> Result<(), Error> { + self.rt.block_on(self.api.remove_tracker(hash, url)) + } } diff --git a/src/api/qbittorrent/types.rs b/src/api/qbittorrent/types.rs index 618195e..8c0484a 100644 --- a/src/api/qbittorrent/types.rs +++ b/src/api/qbittorrent/types.rs @@ -1,4 +1,4 @@ -use crate::api::{Torrent, IntoTorrent, calc_progress}; +use crate::api::{Torrent, IntoTorrent}; /// Deserializes from the 'properties' endpoint of QBittorrent API /// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-generic-properties @@ -19,20 +19,6 @@ pub struct QBittorrentPropertiesTorrent { pub size: i64, } -impl IntoTorrent for QBittorrentPropertiesTorrent { - fn into_torrent(&self, hash: &str) -> Torrent { - Torrent { - hash: hash.to_string(), - name: self.name.to_string(), - path: self.path.to_string(), - date_start: self.date_start, - date_end: self.date_end, - progress: calc_progress(self.pieces_have as f64, self.pieces_total as f64), - size: self.size, - } - } -} - /// Deserializes from the 'info' endpoint of QBittorrent API /// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list #[derive(Clone, Deserialize)] @@ -51,7 +37,7 @@ pub struct QBittorrentListTorrent { } impl IntoTorrent for QBittorrentListTorrent { - fn into_torrent(&self, _hash: &str) -> Torrent { + fn into_torrent(&self) -> Torrent { Torrent { hash: self.hash.to_string(), name: self.name.to_string(), diff --git a/src/api/target.rs b/src/api/target.rs new file mode 100644 index 0000000..6256396 --- /dev/null +++ b/src/api/target.rs @@ -0,0 +1,33 @@ +use argh::FromArgValue; + +use crate::Error; +use crate::api::qbittorrent::ApiClient; + +#[derive(Debug)] +pub enum TorrentTarget { + All, + Hash(String), +} + +impl TorrentTarget { + // TODO: replace ApiClient here with a trait so it can be cleanly separated even with async API, or transmission APi + pub fn to_vec(&self, api: &ApiClient) -> Result, Error> { + match self { + TorrentTarget::All => { + Ok(api.list()?.into_iter().map(|t| t.hash.clone()).collect()) + }, TorrentTarget::Hash(s) => { + Ok(vec!(s.clone())) + } + } + } +} + +impl FromArgValue for TorrentTarget { + fn from_arg_value(value: &str) -> Result { + if value == "all" { + Ok(TorrentTarget::All) + } else { + Ok(TorrentTarget::Hash(value.to_string())) + } + } +} diff --git a/src/api/torrent.rs b/src/api/torrent.rs new file mode 100644 index 0000000..fea90ac --- /dev/null +++ b/src/api/torrent.rs @@ -0,0 +1,15 @@ +pub trait IntoTorrent { + fn into_torrent(&self) -> Torrent; +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Torrent { + pub hash: String, + pub name: String, + pub path: String, + pub date_start: i64, + pub date_end: i64, + /// Progress percentage (0-100) + pub progress: u8, + pub size: i64, +} diff --git a/src/api/tracker.rs b/src/api/tracker.rs new file mode 100644 index 0000000..de5e44a --- /dev/null +++ b/src/api/tracker.rs @@ -0,0 +1,13 @@ +#[derive(Debug, Serialize, Deserialize)] +pub struct TorrentTracker { + pub url: String, + pub status: usize, + pub msg: String, +} + +impl TorrentTracker { + // Filtering out DHT + pub fn is_tracker(&self) -> bool { + ! self.url.starts_with("**") + } +} diff --git a/src/error.rs b/src/error.rs index c5b6273..5c6b5b7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -36,6 +36,8 @@ pub enum Error { FailedToReadFile { path: PathBuf, source: std::io::Error }, #[snafu(display("Failed to deserialize JSON response into concrete type:\n{}", source))] FailedDeserialize { source: serde_json::Error }, + #[snafu(display("Failed due to multiple errors:\n{}", sources.iter().map(|e| e.to_string()).collect::>().join("\n\n")))] + MultipleErrors { sources: Vec }, } impl Error { diff --git a/vendor/qbittorrent_web_api b/vendor/qbittorrent_web_api new file mode 160000 index 0000000..a0c3fb9 --- /dev/null +++ b/vendor/qbittorrent_web_api @@ -0,0 +1 @@ +Subproject commit a0c3fb9d5d86fccc806c95c56361c61c0b62f30a