diff --git a/src/.error.rs.swp b/src/.error.rs.swp deleted file mode 100644 index 229bff4..0000000 Binary files a/src/.error.rs.swp and /dev/null differ diff --git a/src/action/.add.rs.swp b/src/action/.add.rs.swp deleted file mode 100644 index 4ccaed1..0000000 Binary files a/src/action/.add.rs.swp and /dev/null differ diff --git a/src/action/add.rs b/src/action/add.rs index 42f73d5..0f60d46 100644 --- a/src/action/add.rs +++ b/src/action/add.rs @@ -24,7 +24,7 @@ impl ActionExec for AddAction { let magnet = if self.torrent.starts_with("magnet:") { self.torrent.clone() } else { - torrent_to_magnet(&self.torrent) + torrent_to_magnet(&self.torrent)? }; let api = ApiClient::from_config(&config)?; api.add(&magnet, self.paused) diff --git a/src/action/hash.rs b/src/action/hash.rs index 86e5920..d4b95d1 100644 --- a/src/action/hash.rs +++ b/src/action/hash.rs @@ -18,9 +18,9 @@ impl ActionExec for HashAction { fn exec(&self, _config: &Config) -> Result<(), Error> { // TODO errors let hash = if self.torrent.starts_with("magnet:") { - magnet_hash(&self.torrent) + magnet_hash(&self.torrent)? } else { - torrent_hash(&self.torrent) + torrent_hash(&self.torrent)? }; println!("{}", hash); Ok(()) diff --git a/src/action/magnet.rs b/src/action/magnet.rs index 452996d..8867c15 100644 --- a/src/action/magnet.rs +++ b/src/action/magnet.rs @@ -19,7 +19,7 @@ impl ActionExec for MagnetAction { if self.torrent.starts_with("magnet:") { println!("{}", &self.torrent); } else { - println!("{}", torrent_to_magnet(&self.torrent)); + println!("{}", torrent_to_magnet(&self.torrent)?); } Ok(()) } diff --git a/src/action/name.rs b/src/action/name.rs index 3a2e304..874b58d 100644 --- a/src/action/name.rs +++ b/src/action/name.rs @@ -1,9 +1,11 @@ use argh::FromArgs; +use snafu::prelude::*; use crate::Error; use crate::action::ActionExec; use crate::config::Config; use crate::utils::{magnet_name, torrent_name}; +use crate::error::EmptyNameMagnetSnafu as EmptyNameMagnet; #[derive(FromArgs, PartialEq, Debug)] #[argh(subcommand, name = "name")] @@ -17,9 +19,10 @@ pub struct NameAction { impl ActionExec for NameAction { fn exec(&self, _config: &Config) -> Result<(), Error> { let name = if self.torrent.starts_with("magnet:") { - magnet_name(&self.torrent) + magnet_name(&self.torrent)? + .context(EmptyNameMagnet { magnet: &self.torrent })? } else { - torrent_name(&self.torrent) + torrent_name(&self.torrent)? }; println!("{}", name); Ok(()) diff --git a/src/api.rs b/src/api.rs index ee1dba8..0418764 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,40 +1,100 @@ 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 struct ApiClient { - pub rt: tokio::runtime::Runtime, - pub api: qbittorrent_web_api::api_impl::Authenticated +pub fn blocking_runtime() -> std::io::Result { + Builder::new_current_thread() + .enable_all() + .build() } -impl ApiClient { - pub fn from_config(config: &Config) -> Result { - Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password) - } +#[derive(Debug)] +pub struct UnauthenticatedRawApiClient { + pub rt: tokio::runtime::Runtime, +} - pub fn new(host: &str, login: &str, password: &str) -> Result { - let rt = Builder::new_current_thread() - .enable_all() - .build().context(IOError)?; - // Call the asynchronous connect method using the runtime. - let api = rt.block_on(Api::login(host, &login, &password)).context(ApiError)?; - Ok(ApiClient { - rt, - api, +impl UnauthenticatedRawApiClient { + /// Initialize a blocking runtime for the API client + pub fn new() -> Result { + Ok(UnauthenticatedRawApiClient { + rt: blocking_runtime()?, }) } - pub fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> { + /// 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) }; - let res = self.rt.block_on(call.send_raw()).context(ApiError)?; + 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 { @@ -43,7 +103,7 @@ impl ApiClient { } pub fn get(&self, hash: &str) -> Result, Error> { - let res = self.rt.block_on(self.api.torrent_management().properties_raw(hash)).context(ApiError)?; + let res = self.raw_api.get(hash).context(ApiError)?; if res == "" { Ok(None) } else { @@ -52,12 +112,50 @@ impl ApiClient { } pub fn list(&self) -> Result { - Ok( - self.rt.block_on(self.api.torrent_management().info().send_raw()).context(ApiError)? - ) + 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/error.rs b/src/error.rs index 2bca881..8e45a01 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,8 @@ use snafu::prelude::*; //use snafu::*; +use std::path::PathBuf; + use crate::config::ConfigError; #[derive(Debug, Snafu)] @@ -17,7 +19,21 @@ pub enum Error { InternalApi { source: qbittorrent_web_api::api_impl::Error }, #[snafu(display("Other error:\n{}", message))] Message { message: String }, - //GenericIOError(std::io::Error), + // New and better error messages + #[snafu(display("Failed to reach qBittorrent API:\n{}", source))] + FailedToReachAPI { source: qbittorrent_web_api::api_impl::Error }, + #[snafu(display("Failed to login to qBittorrent API with user: {}", user))] + FailedLogin { user: String }, + #[snafu(display("Invalid torrent: {}", torrent.display()))] + InvalidTorrent { torrent: PathBuf, source: imdl::error::Error }, + #[snafu(display("Invalid magnet: {}", magnet))] + InvalidMagnet { magnet: String, source: imdl::magnet_link_parse_error::MagnetLinkParseError }, + #[snafu(display("Other torrent error inside imdl library with file {}:\n{}", torrent.display(), source))] + OtherTorrent { torrent: PathBuf, source: imdl::error::Error }, + #[snafu(display("The magnet link contains no name: {}", magnet))] + 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 }, } impl Error { diff --git a/src/utils.rs b/src/utils.rs index 2d186b9..4579d1a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,5 @@ +use snafu::prelude::*; + use imdl::infohash::Infohash; use imdl::input::Input; use imdl::input_target::InputTarget; @@ -6,12 +8,15 @@ use imdl::metainfo::Metainfo; use imdl::torrent_summary::TorrentSummary; use std::path::Path; +use std::net::TcpListener; + +use crate::error::{Error, InvalidMagnetSnafu as InvalidMagnet, InvalidTorrentSnafu as InvalidTorrent, FailedToReadFileSnafu as FailedToReadFile, OtherTorrentSnafu as OtherTorrentError}; /// Helper method for imdl functions which expect an imdl::Input type -pub fn input_path>(path: T) -> Result { +pub fn input_path>(path: T) -> Result { let path = path.as_ref(); - let absolute = path.canonicalize()?; - let data = std::fs::read(absolute)?; + let absolute = path.canonicalize().context(FailedToReadFile { path: path.to_path_buf() })?; + let data = std::fs::read(absolute).context(FailedToReadFile { path: path.to_path_buf() })?; Ok(Input::new( InputTarget::Path(path.to_path_buf()), data @@ -19,53 +24,62 @@ pub fn input_path>(path: T) -> Result { } /// Extracts the infohash of a magnet link -pub fn magnet_hash>(magnet: T) -> String { - // TODO: error - let magnet = MagnetLink::parse(magnet.as_ref()).expect("Parsing magnet failed"); - //Ok(magnet.name.expect("Magnet link has no name!")) - //Ok(String::from_utf8_lossy(&magnet.infohash.inner.bytes).to_string()) +pub fn magnet_hash>(magnet: T) -> Result { + let magnet = magnet.as_ref(); + let magnet = MagnetLink::parse(magnet).context(InvalidMagnet { magnet: magnet.to_string() })?; let bytes = magnet.infohash.inner.bytes.clone(); let mut s = String::new(); for byte in bytes { s.push_str(&format!("{:02x}", byte)); } - s + Ok(s) } /// Extracts the name of a magnet link -pub fn magnet_name>(magnet: T) -> String { - // TODO: error - let magnet = MagnetLink::parse(magnet.as_ref()).expect("Parsing magnet failed"); - magnet.name.expect("Magnet link has no name!") +pub fn magnet_name>(magnet: T) -> Result, Error> { + let magnet = magnet.as_ref(); + let magnet = MagnetLink::parse(magnet).context(InvalidMagnet { magnet })?; + Ok(magnet.name) } /// Extracts the infohash of a torrent file -pub fn torrent_hash>(torrent: T) -> String { - // TODO: error - let input = input_path(torrent).unwrap(); - TorrentSummary::from_input(&input).expect("Parsing torrent file failed").torrent_summary_data().info_hash +pub fn torrent_hash>(torrent: T) -> Result { + let torrent = torrent.as_ref(); + let input = input_path(torrent)?; + let summary = TorrentSummary::from_input(&input).context(InvalidTorrent { torrent })?; + Ok(summary.torrent_summary_data().info_hash) } /// Extracts the name of a torrent file -pub fn torrent_name>(torrent: T) -> String { - let input = input_path(torrent).unwrap(); - TorrentSummary::from_input(&input).expect("Parsing torrent file failed").torrent_summary_data().name +pub fn torrent_name>(torrent: T) -> Result { + let torrent = torrent.as_ref(); + let input = input_path(torrent)?; + let summary = TorrentSummary::from_input(&input).context(InvalidTorrent { torrent })?; + Ok(summary.torrent_summary_data().name) } /// Turns a torrent file into a magnet link -pub fn torrent_to_magnet>(torrent: T) -> String { - let input = input_path(torrent).expect("Failed to read torrent file"); - let infohash = Infohash::from_input(&input).expect("Failed to parse infohash"); - let metainfo = Metainfo::from_input(&input).expect("Failed to parse meta info"); +pub fn torrent_to_magnet>(torrent: T) -> Result { + let torrent = torrent.as_ref(); + let input = input_path(torrent)?; + let infohash = Infohash::from_input(&input).context(InvalidTorrent { torrent })?; + let metainfo = Metainfo::from_input(&input).context(InvalidTorrent { torrent })?; let mut link = MagnetLink::with_infohash(infohash); link.set_name(&metainfo.info.name); for result in metainfo.trackers() { - link.add_tracker(result.expect("failed tracker in metainfo")); + let result = result.context(OtherTorrentError { torrent })?; + link.add_tracker(result); } - link.to_url().to_string() + Ok(link.to_url().to_string()) } + +// Fallible: only used in tests +pub fn find_free_port() -> u16 { + let bind = TcpListener::bind("127.0.0.1:0").unwrap(); + bind.local_addr().unwrap().port() +}