API has 4 variants: sync/async * raw/typed

This commit is contained in:
programmer programmer 2022-10-30 17:33:35 +01:00
parent 3002a19b1c
commit a144084ac2
13 changed files with 341 additions and 278 deletions

1
Cargo.lock generated
View File

@ -1231,6 +1231,7 @@ dependencies = [
"imdl",
"qbittorrent-web-api",
"serde",
"serde_json",
"snafu 0.7.2",
"tokio",
"toml",

View File

@ -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"

View File

@ -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;

View File

@ -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(())
}
}

View File

@ -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(())
}
}

View File

@ -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<tokio::runtime::Runtime> {
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<UnauthenticatedRawApiClient, std::io::Error> {
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<RawApiClient, QBittorrentError> {
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<String, QBittorrentError> {
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<String, QBittorrentError> {
self.rt.block_on(self.api.torrent_management().properties_raw(hash))
}
pub fn list(&self) -> Result<String, QBittorrentError> {
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<ApiClient, Error> {
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<ApiClient, Error> {
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<Option<String>, Error> {
let res = self.raw_api.get(hash).context(ApiError)?;
if res == "" {
Ok(None)
} else {
Ok(Some(res))
}
}
pub fn list(&self) -> Result<String, Error> {
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");
}
}
}
}

56
src/api/mod.rs Normal file
View File

@ -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<Torrent>);
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<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromIterator<Torrent> for TorrentList {
fn from_iter<I: IntoIterator<Item=Torrent>>(iter: I) -> Self {
let mut c = TorrentList::new();
for i in iter {
c.push(i);
}
c
}
}

181
src/api/qbittorrent/mod.rs Normal file
View File

@ -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<Runtime> {
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<RawAsyncApiClient, Error> {
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<String, Error> {
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<String, Error> {
self.api.torrent_management().properties_raw(hash).await.context(ApiError)
}
#[allow(dead_code)]
pub async fn list(&self) -> Result<String, Error> {
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<RawApiClient, Error> {
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<RawApiClient, Error> {
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<String, Error> {
self.rt.block_on(self.api.add(magnet, paused))
}
#[allow(dead_code)]
pub fn get(&self, hash: &str) -> Result<String, Error> {
self.rt.block_on(self.api.get(hash))
}
#[allow(dead_code)]
pub fn list(&self) -> Result<String, Error> {
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<AsyncApiClient, Error> {
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<AsyncApiClient, Error> {
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<Option<Torrent>, 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<TorrentList, Error> {
let res = self.raw_api.list().await?;
let concrete: Vec<QBittorrentListTorrent> = 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<ApiClient, Error> {
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<ApiClient, Error> {
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<Option<Torrent>, Error> {
self.rt.block_on(self.api.get(hash))
}
#[allow(dead_code)]
pub fn list(&self) -> Result<TorrentList, Error> {
self.rt.block_on(self.api.list())
}
}

View File

@ -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,
}
}
}

View File

@ -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<RawApiClient, QBittorrentError> {
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<String, QBittorrentError> {
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<String, QBittorrentError> {
self.api.torrent_management().properties_raw(hash).await
}
pub async fn list(&self) -> Result<String, QBittorrentError> {
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<ApiClient, Error> {
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<ApiClient, Error> {
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<Option<String>, 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<String, Error> {
self.raw_api.list().await.context(ApiError)
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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;