From a144084ac25e945514f977df9de1888e19695e94 Mon Sep 17 00:00:00 2001 From: "programmer@kl.netlib.re" Date: Sun, 30 Oct 2022 17:33:35 +0100 Subject: [PATCH] API has 4 variants: sync/async * raw/typed --- Cargo.lock | 1 + Cargo.toml | 1 + src/action/add.rs | 2 +- src/action/get.rs | 19 +++- src/action/list.rs | 20 +++- src/api.rs | 161 ------------------------------- src/api/mod.rs | 56 +++++++++++ src/api/qbittorrent/mod.rs | 181 +++++++++++++++++++++++++++++++++++ src/api/qbittorrent/types.rs | 65 +++++++++++++ src/async_api.rs | 105 -------------------- src/error.rs | 2 + src/lib.rs | 4 +- src/main.rs | 2 + 13 files changed, 341 insertions(+), 278 deletions(-) delete mode 100644 src/api.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/qbittorrent/mod.rs create mode 100644 src/api/qbittorrent/types.rs delete mode 100644 src/async_api.rs diff --git a/Cargo.lock b/Cargo.lock index 6a899c4..514cc94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,7 @@ dependencies = [ "imdl", "qbittorrent-web-api", "serde", + "serde_json", "snafu 0.7.2", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 479bbaf..f158c4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] toml = "0.5" serde = { version = "1.0", features = ["derive"] } +serde_json = "1" xdg = "2.4" snafu = "0.7" qbittorrent-web-api = "0.6" diff --git a/src/action/add.rs b/src/action/add.rs index 0f60d46..171973c 100644 --- a/src/action/add.rs +++ b/src/action/add.rs @@ -1,6 +1,6 @@ use argh::FromArgs; -use crate::api::ApiClient; +use crate::api::qbittorrent::ApiClient; use crate::Error; use crate::action::ActionExec; use crate::config::Config; diff --git a/src/action/get.rs b/src/action/get.rs index ba133d1..2314a4f 100644 --- a/src/action/get.rs +++ b/src/action/get.rs @@ -1,7 +1,7 @@ use argh::FromArgs; use crate::action::ActionExec; -use crate::api::ApiClient; +use crate::api::qbittorrent::{ApiClient, RawApiClient}; use crate::config::Config; use crate::error::Error; @@ -12,6 +12,9 @@ pub struct GetAction { #[argh(switch)] /// the positional argument is a magnet link, not an infohash magnet: bool, + #[argh(switch, short = 'r')] + /// return raw JSON response from API + raw: bool, #[argh(positional)] /// the infohash (or magnet link with --magnet) to retrieve torrent: String, @@ -19,11 +22,17 @@ pub struct GetAction { impl ActionExec for GetAction { fn exec(&self, config: &Config) -> Result<(), Error> { - let api = ApiClient::from_config(&config)?; - let res = api.get(&self.torrent)?; - if let Some(s) = res { - println!("{}", s) + if self.raw { + let api = RawApiClient::from_config(&config)?; + let res = api.get(&self.torrent)?; + println!("{}", res); + } else { + let api = ApiClient::from_config(&config)?; + if let Some(t) = api.get(&self.torrent)? { + println!("{}", t.hash); + } } + Ok(()) } } diff --git a/src/action/list.rs b/src/action/list.rs index 84923e3..2c3ad70 100644 --- a/src/action/list.rs +++ b/src/action/list.rs @@ -1,6 +1,6 @@ use argh::FromArgs; -use crate::api::ApiClient; +use crate::api::qbittorrent::{ApiClient, RawApiClient}; use crate::Error; use crate::action::ActionExec; use crate::config::Config; @@ -8,13 +8,23 @@ use crate::config::Config; #[derive(FromArgs, PartialEq, Debug)] #[argh(subcommand, name = "list")] /// list existing torrents on qBittorrent -pub struct ListAction {} +pub struct ListAction { + #[argh(switch, short = 'r')] + /// return raw JSON response from API + raw: bool, +} impl ActionExec for ListAction { fn exec(&self, config: &Config) -> Result<(), Error> { - let api = ApiClient::from_config(&config)?; - let res = api.list()?; - println!("{}", res); + if self.raw { + let api = RawApiClient::from_config(&config)?; + println!("{}", api.list()?); + } else { + let api = ApiClient::from_config(&config)?; + for torrent in api.list()? { + println!("{}", torrent.hash); + } + } Ok(()) } } diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 0418764..0000000 --- a/src/api.rs +++ /dev/null @@ -1,161 +0,0 @@ -use snafu::ResultExt; -use qbittorrent_web_api::Api; -use qbittorrent_web_api::api_impl::Error as QBittorrentError; -use tokio::runtime::Builder; - -use crate::config::Config; -use crate::error::{Error, ApiSnafu as IOError, InternalApiSnafu as ApiError}; - -pub fn blocking_runtime() -> std::io::Result { - Builder::new_current_thread() - .enable_all() - .build() -} - -#[derive(Debug)] -pub struct UnauthenticatedRawApiClient { - pub rt: tokio::runtime::Runtime, -} - -impl UnauthenticatedRawApiClient { - /// Initialize a blocking runtime for the API client - pub fn new() -> Result { - Ok(UnauthenticatedRawApiClient { - rt: blocking_runtime()?, - }) - } - - /// Login into a qBittorrent backend and return a proper ApiClient instance - pub fn login(self, host: &str, login: &str, password: &str) -> Result { - let api = self.rt.block_on(Api::login(host, login, password))?; - Ok(RawApiClient { - rt: self.rt, - api, - }) - } -} - -#[derive(Debug)] -pub struct RawApiClient { - rt: tokio::runtime::Runtime, - api: qbittorrent_web_api::api_impl::Authenticated, -} - -impl RawApiClient { - pub fn add(&self, magnet: &str, paused: bool) -> Result { - let base_call = self.api.torrent_management(); - let call = if paused { - base_call.add(magnet).paused("true") - } else { - base_call.add(magnet) - }; - self.rt.block_on(call.send_raw()) - } - - pub fn get(&self, hash: &str) -> Result { - self.rt.block_on(self.api.torrent_management().properties_raw(hash)) - } - - pub fn list(&self) -> Result { - self.rt.block_on(self.api.torrent_management().info().send_raw()) - } - -} - -#[derive(Debug)] -/// ApiClient is a convenience struct around qbittorrent_web_api for use in async programs, using qbt::Error error types. -pub struct ApiClient { - pub raw_api: RawApiClient, -} - -impl ApiClient { - /// Login to a qBittorrent backend and return a handle to the ApiClient - pub fn new(host: &str, login: &str, password: &str) -> Result { - let unauthenticated = UnauthenticatedRawApiClient::new().context(IOError)?; - let authenticated = unauthenticated.login(host, login, password).map_err(|e| { - match e { - QBittorrentError::HttpError(_) => { - Error::FailedToReachAPI { source: e } - }, QBittorrentError::InvalidUsernameOrPassword => { - Error::FailedLogin { user: login.to_string() } - } _ => { - panic!("Cookie error"); - } - } - })?; - Ok(ApiClient { - raw_api: authenticated, - }) - } - - pub fn from_config(config: &Config) -> Result { - Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password) - } - - - pub fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> { - let res = self.raw_api.add(magnet, paused).context(ApiError)?; - if res == "Ok." { - Ok(()) - } else { - Err(Error::message(res.clone())) - } - } - - pub fn get(&self, hash: &str) -> Result, Error> { - let res = self.raw_api.get(hash).context(ApiError)?; - if res == "" { - Ok(None) - } else { - Ok(Some(res)) - } - } - - pub fn list(&self) -> Result { - self.raw_api.list().context(ApiError) - } - -} - -// TODO: typestate builder https://www.greyblake.com/blog/builder-with-typestate-in-rust/ -//struct ApiClientBuilder {} - -#[cfg(test)] -mod tests { - use crate::utils::*; - use crate::api::{ApiClient, UnauthenticatedRawApiClient, QBittorrentError}; - use crate::error::Error; - - #[test] - fn raw_wrong_server() { - let port = find_free_port(); - let api = UnauthenticatedRawApiClient::new().expect("IOERROR"); - let login = api.login(&format!("http://localhost:{}", &port), "admin", "adminadmin"); - assert!(login.is_err()); - let err = login.unwrap_err(); - match &err { - QBittorrentError::HttpError(_) => { - return; - }, _ => { - println!("{:?}", err); - panic!("API CHANGE!"); - } - } - } - - #[test] - fn wrong_server() { - let port = find_free_port(); - let api = ApiClient::new(&format!("http://localhost:{}", &port), "admin", "adminadmin"); - assert!(api.is_err()); - let err = api.unwrap_err(); - match &err { - Error::FailedToReachAPI { source: _ } => { - return; - }, _ => { - println!("{:?}", err); - panic!("ERROR CONVERSION PROBLEM"); - } - } - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..d18ad00 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,56 @@ +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 + } +} diff --git a/src/api/qbittorrent/mod.rs b/src/api/qbittorrent/mod.rs new file mode 100644 index 0000000..844f581 --- /dev/null +++ b/src/api/qbittorrent/mod.rs @@ -0,0 +1,181 @@ +use snafu::prelude::*; +use qbittorrent_web_api::Api; +use tokio::runtime::{Builder, Runtime}; + +use crate::Config; +use crate::error::{Error, ApiSnafu as IOError, InternalApiSnafu as ApiError, FailedDeserializeSnafu as DeserializeError}; + +mod types; +pub use types::QBittorrentPropertiesTorrent; +pub use types::QBittorrentListTorrent; +use crate::api::{Torrent, TorrentList, IntoTorrent}; + +pub fn blocking_runtime() -> std::io::Result { + Builder::new_current_thread() + .enable_all() + .build() +} + +#[derive(Debug)] +pub struct UnauthenticatedRawAsyncApiClient; + +impl UnauthenticatedRawAsyncApiClient { + pub async fn login(host: &str, login: &str, password: &str) -> Result { + Ok(RawAsyncApiClient { + api: Api::login(host, login, password).await.context(ApiError)?, + }) + } +} + +#[derive(Debug)] +pub struct RawAsyncApiClient { + pub api: qbittorrent_web_api::api_impl::Authenticated, +} + +impl RawAsyncApiClient { + #[allow(dead_code)] + pub async fn add(&self, magnet: &str, paused: bool) -> Result { + if paused { + self.api.torrent_management().add(magnet).paused("true").send_raw().await.context(ApiError) + } else { + self.api.torrent_management().add(magnet).send_raw().await.context(ApiError) + } + } + + #[allow(dead_code)] + pub async fn get(&self, hash: &str) -> Result { + self.api.torrent_management().properties_raw(hash).await.context(ApiError) + } + + #[allow(dead_code)] + pub async fn list(&self) -> Result { + self.api.torrent_management().info().send_raw().await.context(ApiError) + } +} + +#[derive(Debug)] +pub struct RawApiClient { + pub rt: Runtime, + pub api: RawAsyncApiClient, +} + +impl RawApiClient { + pub fn from_config(config: &Config) -> Result { + Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password) + } + + /// Login into a qBittorrent backend and return a proper ApiClient instance + #[allow(dead_code)] + pub fn new(host: &str, login: &str, password: &str) -> Result { + let rt = blocking_runtime().context(IOError)?; + let api = rt.block_on(UnauthenticatedRawAsyncApiClient::login(host, login, password))?; + Ok(RawApiClient { + rt, + api, + }) + } + + #[allow(dead_code)] + pub fn add(&self, magnet: &str, paused: bool) -> Result { + self.rt.block_on(self.api.add(magnet, paused)) + } + + #[allow(dead_code)] + pub fn get(&self, hash: &str) -> Result { + self.rt.block_on(self.api.get(hash)) + } + + #[allow(dead_code)] + pub fn list(&self) -> Result { + self.rt.block_on(self.api.list()) + } +} + +#[derive(Debug)] +pub struct AsyncApiClient { + pub raw_api: RawAsyncApiClient, +} + +impl AsyncApiClient { + #[allow(dead_code)] + pub async fn from_config(config: &Config) -> Result { + Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password).await + } + + #[allow(dead_code)] + pub async fn new(host: &str, login: &str, password: &str) -> Result { + let api = UnauthenticatedRawAsyncApiClient::login(host, login, password).await?; + + Ok(AsyncApiClient { + raw_api: api, + }) + } + + #[allow(dead_code)] + pub async fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> { + let res = self.raw_api.add(magnet, paused).await?; + if res == "Ok." { + Ok(()) + } else { + Err(Error::message(res.clone())) + } + } + + #[allow(dead_code)] + pub async fn get(&self, hash: &str) -> Result, Error> { + let res = self.raw_api.get(hash).await?; + if res == "" { + Ok(None) + } else { + let concrete: QBittorrentPropertiesTorrent = serde_json::from_str(&res).context(DeserializeError)?; + Ok(Some(concrete.into_torrent(hash))) + } + } + + #[allow(dead_code)] + 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()) + } + +} + +#[derive(Debug)] +pub struct ApiClient { + pub rt: Runtime, + pub api: AsyncApiClient, +} + +impl ApiClient { + #[allow(dead_code)] + pub fn from_config(config: &Config) -> Result { + Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password) + } + + /// Login into a qBittorrent backend and return a proper ApiClient instance + #[allow(dead_code)] + pub fn new(host: &str, login: &str, password: &str) -> Result { + let rt = blocking_runtime().context(IOError)?; + let api = rt.block_on(AsyncApiClient::new(host, login, password))?; + Ok(ApiClient { + rt, + api, + }) + } + + #[allow(dead_code)] + pub fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> { + self.rt.block_on(self.api.add(magnet, paused)) + } + + #[allow(dead_code)] + pub fn get(&self, hash: &str) -> Result, Error> { + self.rt.block_on(self.api.get(hash)) + } + + #[allow(dead_code)] + pub fn list(&self) -> Result { + self.rt.block_on(self.api.list()) + } +} diff --git a/src/api/qbittorrent/types.rs b/src/api/qbittorrent/types.rs new file mode 100644 index 0000000..618195e --- /dev/null +++ b/src/api/qbittorrent/types.rs @@ -0,0 +1,65 @@ +use crate::api::{Torrent, IntoTorrent, calc_progress}; + +/// Deserializes from the 'properties' endpoint of QBittorrent API +/// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-generic-properties +#[derive(Clone, Deserialize)] +pub struct QBittorrentPropertiesTorrent { + pub name: String, + #[serde(rename="save_path")] + pub path: String, + #[serde(rename="addition_date")] + pub date_start: i64, + #[serde(rename="completion_date")] + pub date_end: i64, + #[serde(rename="pieces_have")] + pub pieces_have: i64, + #[serde(rename="pieces_num")] + pub pieces_total: i64, + #[serde(rename="total_size")] + 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)] +pub struct QBittorrentListTorrent { + pub hash: String, + pub name: String, + #[serde(rename="save_path")] + pub path: String, + #[serde(rename="added_on")] + pub date_start: i64, + #[serde(rename="completion_on")] + pub date_end: i64, + pub progress: f32, + #[serde(rename="total_size")] + pub size: i64, +} + +impl IntoTorrent for QBittorrentListTorrent { + fn into_torrent(&self, _hash: &str) -> Torrent { + Torrent { + hash: self.hash.to_string(), + name: self.name.to_string(), + path: self.path.to_string(), + date_start: self.date_start, + date_end: self.date_end, + progress: (self.progress * 100.0) as u8, + size: self.size, + } + } +} diff --git a/src/async_api.rs b/src/async_api.rs deleted file mode 100644 index c5a61fb..0000000 --- a/src/async_api.rs +++ /dev/null @@ -1,105 +0,0 @@ -use snafu::ResultExt; -use qbittorrent_web_api::Api; -use qbittorrent_web_api::api_impl::Error as QBittorrentError; - -use crate::config::Config; -use crate::error::{Error, InternalApiSnafu as ApiError}; - -#[derive(Debug)] -pub struct UnauthenticatedRawApiClient; - -impl UnauthenticatedRawApiClient { - /// Initialize a blocking runtime for the API client - pub fn new() -> UnauthenticatedRawApiClient { - UnauthenticatedRawApiClient - } - - /// Login into a qBittorrent backend and return a proper ApiClient instance - pub async fn login(self, host: &str, login: &str, password: &str) -> Result { - let api = Api::login(host, login, password).await?; - Ok(RawApiClient { - api, - }) - } -} - -#[derive(Debug)] -pub struct RawApiClient { - api: qbittorrent_web_api::api_impl::Authenticated, -} - -impl RawApiClient { - pub async fn add(&self, magnet: &str, paused: bool) -> Result { - let base_call = self.api.torrent_management(); - let call = if paused { - base_call.add(magnet).paused("true") - } else { - base_call.add(magnet) - }; - call.send_raw().await - } - - pub async fn get(&self, hash: &str) -> Result { - self.api.torrent_management().properties_raw(hash).await - } - - pub async fn list(&self) -> Result { - self.api.torrent_management().info().send_raw().await - } - -} - -#[derive(Debug)] -/// ApiClient is a convenience struct around qbittorrent_web_api for use in async programs, using qbt::Error error types. -pub struct ApiClient { - pub raw_api: RawApiClient, -} - -impl ApiClient { - /// Login to a qBittorrent backend and return a handle to the ApiClient - pub async fn new(host: &str, login: &str, password: &str) -> Result { - let unauthenticated = UnauthenticatedRawApiClient::new(); - let authenticated = unauthenticated.login(host, login, password).await.map_err(|e| { - match e { - QBittorrentError::HttpError(_) => { - Error::FailedToReachAPI { source: e } - }, QBittorrentError::InvalidUsernameOrPassword => { - Error::FailedLogin { user: login.to_string() } - } _ => { - panic!("Cookie error"); - } - } - })?; - Ok(ApiClient { - raw_api: authenticated, - }) - } - - pub async fn from_config(config: &Config) -> Result { - Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password).await - } - - - pub async fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> { - let res = self.raw_api.add(magnet, paused).await.context(ApiError)?; - if res == "Ok." { - Ok(()) - } else { - Err(Error::message(res.clone())) - } - } - - pub async fn get(&self, hash: &str) -> Result, Error> { - let res = self.raw_api.get(hash).await.context(ApiError)?; - if res == "" { - Ok(None) - } else { - Ok(Some(res)) - } - } - - pub async fn list(&self) -> Result { - self.raw_api.list().await.context(ApiError) - } - -} diff --git a/src/error.rs b/src/error.rs index 8e45a01..c5b6273 100644 --- a/src/error.rs +++ b/src/error.rs @@ -34,6 +34,8 @@ pub enum Error { EmptyNameMagnet { magnet: String }, #[snafu(display("Failed to read file {} because of underlying IO error:\n{}", path.display(), source))] FailedToReadFile { path: PathBuf, source: std::io::Error }, + #[snafu(display("Failed to deserialize JSON response into concrete type:\n{}", source))] + FailedDeserialize { source: serde_json::Error }, } impl Error { diff --git a/src/lib.rs b/src/lib.rs index df9f167..a491fd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ +#[macro_use] extern crate serde; + pub mod action; pub mod api; -pub mod async_api; +pub use api::{Torrent, TorrentList, IntoTorrent}; pub mod config; pub use config::Config; pub mod cli; diff --git a/src/main.rs b/src/main.rs index 13228c4..9424525 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ +#[macro_use] extern crate serde; use snafu::prelude::*; mod action; mod api; +pub use api::{Torrent, TorrentList, IntoTorrent}; mod config; use config::Config; mod cli;