#[macro_use] extern crate log; use reqwest::{header::CONTENT_TYPE, Client, StatusCode, Url}; use serde::de::DeserializeOwned; #[cfg(feature = "sync")] mod sync; #[cfg(feature = "sync")] pub use sync::SharableTransClient; pub mod types; use types::{ BasicAuth, BlocklistUpdate, FreeSpace, Id, Nothing, PortTest, Result, RpcRequest, RpcResponse, RpcResponseArgument, SessionClose, SessionGet, SessionStats, Torrent, TorrentAction, TorrentAddArgs, TorrentAddedOrDuplicate, TorrentGetField, TorrentRenamePath, TorrentSetArgs, Torrents, }; const MAX_RETRIES: usize = 5; #[derive(Clone, Debug)] enum TransError { MaxRetriesReached, NoSessionIdReceived, } impl std::fmt::Display for TransError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match *self { TransError::MaxRetriesReached => write!(f, "Max retries reached!"), TransError::NoSessionIdReceived => write!(f, "No session id received!"), } } } impl std::error::Error for TransError {} pub struct TransClient { url: Url, auth: Option, session_id: Option, client: Client, } impl TransClient { /// Returns HTTP(S) client with configured Basic Auth #[must_use] pub fn with_auth(url: Url, basic_auth: BasicAuth) -> TransClient { TransClient { url, auth: Some(basic_auth), session_id: None, client: Client::new(), } } /// Returns HTTP(S) client #[must_use] pub fn new(url: Url) -> TransClient { TransClient { url, auth: None, session_id: None, client: Client::new(), } } #[must_use] pub fn new_with_client(url: Url, client: Client) -> TransClient { TransClient { url, auth: None, session_id: None, client, } } pub fn set_auth(&mut self, basic_auth: BasicAuth) { self.auth = Some(basic_auth); } /// Prepares a request for provided server and auth fn rpc_request(&self) -> reqwest::RequestBuilder { if let Some(auth) = &self.auth { self.client .post(self.url.clone()) .basic_auth(&auth.user, Some(&auth.password)) } else { self.client.post(self.url.clone()) } .header(CONTENT_TYPE, "application/json") } /// Performs a session get call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Result, RpcResponse, SessionGet}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.session_get().await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn session_get(&mut self) -> Result> { self.call(RpcRequest::session_get()).await } /// Performs a session stats call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Result, RpcResponse, SessionStats}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.session_stats().await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn session_stats(&mut self) -> Result> { self.call(RpcRequest::session_stats()).await } /// Performs a session close call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Result, RpcResponse, SessionClose}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.session_close().await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn session_close(&mut self) -> Result> { self.call(RpcRequest::session_close()).await } /// Performs a blocklist update call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, BlocklistUpdate, Result, RpcResponse}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.blocklist_update().await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn blocklist_update(&mut self) -> Result> { self.call(RpcRequest::blocklist_update()).await } /// Performs a session stats call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, FreeSpace, Result, RpcResponse}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let dir = env::var("TDIR")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.free_space(dir).await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn free_space(&mut self, path: String) -> Result> { self.call(RpcRequest::free_space(path)).await } /// Performs a port test call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, PortTest, Result, RpcResponse}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let response: Result> = client.port_test().await; /// match response { /// Ok(_) => println!("Yay!"), /// Err(_) => panic!("Oh no!"), /// } /// println!("Rpc response is ok: {}", response?.is_ok()); /// Ok(()) /// } /// ``` pub async fn port_test(&mut self) -> Result> { self.call(RpcRequest::port_test()).await } /// Performs a torrent get call /// fields - if None then ALL fields /// ids - if None then All items /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Result, RpcResponse, Torrent, TorrentGetField, Torrents}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// /// let res: RpcResponse> = client.torrent_get(None, None).await?; /// let names: Vec<&String> = res /// .arguments /// .torrents /// .iter() /// .map(|it| it.name.as_ref().unwrap()) /// .collect(); /// println!("{:#?}", names); /// /// let res1: RpcResponse> = client /// .torrent_get( /// Some(vec![TorrentGetField::Id, TorrentGetField::Name]), /// Some(vec![Id::Id(1), Id::Id(2), Id::Id(3)]), /// ) /// .await?; /// let first_three: Vec = res1 /// .arguments /// .torrents /// .iter() /// .map(|it| { /// format!( /// "{}. {}", /// &it.id.as_ref().unwrap(), /// &it.name.as_ref().unwrap() /// ) /// }) /// .collect(); /// println!("{:#?}", first_three); /// /// let res2: RpcResponse> = client /// .torrent_get( /// Some(vec![ /// TorrentGetField::Id, /// TorrentGetField::HashString, /// TorrentGetField::Name, /// ]), /// Some(vec![Id::Hash(String::from( /// "64b0d9a53ac9cd1002dad1e15522feddb00152fe", /// ))]), /// ) /// .await?; /// let info: Vec = res2 /// .arguments /// .torrents /// .iter() /// .map(|it| { /// format!( /// "{:5}. {:^45} {}", /// &it.id.as_ref().unwrap(), /// &it.hash_string.as_ref().unwrap(), /// &it.name.as_ref().unwrap() /// ) /// }) /// .collect(); /// println!("{:#?}", info); /// /// Ok(()) /// } /// ``` pub async fn torrent_get( &mut self, fields: Option>, ids: Option>, ) -> Result>> { self.call(RpcRequest::torrent_get(fields, ids)).await } /// Performs a torrent set call /// args - the fields to update /// ids - if None then All items /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Result, RpcResponse, Torrent, TorrentSetArgs, Torrents}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// /// let url = env::var("TURL")?.parse()?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url, basic_auth); /// /// let args = TorrentSetArgs { /// labels: Some(vec![String::from("blue")]), /// ..Default::default() /// }; /// assert!( /// client /// .torrent_set(args, Some(vec![Id::Id(0)])) /// .await? /// .is_ok() /// ); /// /// Ok(()) /// } /// ``` pub async fn torrent_set( &mut self, args: TorrentSetArgs, ids: Option>, ) -> Result> { self.call(RpcRequest::torrent_set(args, ids)).await } /// Performs a torrent action call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Nothing, Result, RpcResponse, TorrentAction}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let res1: RpcResponse = client /// .torrent_action(TorrentAction::Start, vec![Id::Id(1)]) /// .await?; /// println!("Start result: {:?}", &res1.is_ok()); /// let res2: RpcResponse = client /// .torrent_action(TorrentAction::Stop, vec![Id::Id(1)]) /// .await?; /// println!("Stop result: {:?}", &res2.is_ok()); /// /// Ok(()) /// } /// ``` pub async fn torrent_action( &mut self, action: TorrentAction, ids: Vec, ) -> Result> { self.call(RpcRequest::torrent_action(action, ids)).await } /// Performs a torrent remove call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Nothing, Result, RpcResponse}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let res: RpcResponse = client.torrent_remove(vec![Id::Id(1)], false).await?; /// println!("Remove result: {:?}", &res.is_ok()); /// /// Ok(()) /// } /// ``` pub async fn torrent_remove( &mut self, ids: Vec, delete_local_data: bool, ) -> Result> { self.call(RpcRequest::torrent_remove(ids, delete_local_data)) .await } /// Performs a torrent set location call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Nothing, Result, RpcResponse}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let res: RpcResponse = client /// .torrent_set_location( /// vec![Id::Id(1)], /// String::from("/new/location"), /// Option::from(false), /// ) /// .await?; /// println!("Set-location result: {:?}", &res.is_ok()); /// /// Ok(()) /// } /// ``` pub async fn torrent_set_location( &mut self, ids: Vec, location: String, move_from: Option, ) -> Result> { self.call(RpcRequest::torrent_set_location(ids, location, move_from)) .await } /// Performs a torrent rename path call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Id, Result, RpcResponse, TorrentRenamePath}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let res: RpcResponse = client /// .torrent_rename_path( /// vec![Id::Id(1)], /// String::from("Folder/OldFile.jpg"), /// String::from("NewFile.jpg"), /// ) /// .await?; /// println!("rename-path result: {:#?}", res); /// /// Ok(()) /// } /// ``` pub async fn torrent_rename_path( &mut self, ids: Vec, path: String, name: String, ) -> Result> { self.call(RpcRequest::torrent_rename_path(ids, path, name)) .await } /// Performs a torrent add call /// /// # Errors /// /// Any IO Error or Deserialization error /// /// # Example /// /// ``` /// extern crate transmission_rpc; /// /// use std::env; /// /// use dotenvy::dotenv; /// use transmission_rpc::{ /// types::{BasicAuth, Result, RpcResponse, TorrentAddArgs, TorrentAddedOrDuplicate}, /// TransClient, /// }; /// /// #[tokio::main] /// async fn main() -> Result<()> { /// dotenv().ok(); /// env_logger::init(); /// let url = env::var("TURL")?; /// let basic_auth = BasicAuth { /// user: env::var("TUSER")?, /// password: env::var("TPWD")?, /// }; /// let mut client = TransClient::with_auth(url.parse()?, basic_auth); /// let add: TorrentAddArgs = TorrentAddArgs { /// filename: Some( /// "https://releases.ubuntu.com/jammy/ubuntu-22.04.1-desktop-amd64.iso.torrent" /// .to_string(), /// ), /// ..TorrentAddArgs::default() /// }; /// let res: RpcResponse = client.torrent_add(add).await?; /// println!("Add result: {:?}", &res.is_ok()); /// println!("response: {:?}", &res); /// /// Ok(()) /// } /// ``` /// /// # Panics /// Either metainfo or torrent filename must be set or this call will panic. pub async fn torrent_add( &mut self, add: TorrentAddArgs, ) -> Result> { assert!( !(add.metainfo == None && add.filename == None), "Metainfo or Filename should be provided" ); self.call(RpcRequest::torrent_add(add)).await } /// Performs a JRPC call to the server /// /// # Errors /// /// Any IO Error or Deserialization error async fn call(&mut self, request: RpcRequest) -> Result> where RS: RpcResponseArgument + DeserializeOwned + std::fmt::Debug, { let mut remaining_retries = MAX_RETRIES; loop { remaining_retries = remaining_retries .checked_sub(1) .ok_or(TransError::MaxRetriesReached)?; debug!("Loaded auth: {:?}", &self.auth); let rq = match &self.session_id { None => self.rpc_request(), Some(id) => self.rpc_request().header("X-Transmission-Session-Id", id), } .json(&request); debug!( "Request body: {:?}", rq.try_clone() .expect("Unable to get the request body") .body_string()? ); let rsp: reqwest::Response = rq.send().await?; if matches!(rsp.status(), StatusCode::CONFLICT) { let session_id = rsp .headers() .get("X-Transmission-Session-Id") .ok_or(TransError::NoSessionIdReceived)? .to_str()?; self.session_id = Some(String::from(session_id)); debug!("Got new session_id: {}. Retrying request.", session_id); } else { let rpc_response: RpcResponse = rsp.json().await?; debug!("Response body: {:#?}", rpc_response); return Ok(rpc_response); } } } } trait BodyString { fn body_string(self) -> Result; } impl BodyString for reqwest::RequestBuilder { fn body_string(self) -> Result { let rq = self.build()?; let body = rq.body().unwrap().as_bytes().unwrap(); Ok(std::str::from_utf8(body)?.to_string()) } } #[cfg(test)] mod tests { use std::env; use dotenvy::dotenv; use super::*; #[tokio::test] pub async fn test_malformed_url() -> Result<()> { dotenv().ok(); env_logger::init(); let url = env::var("TURL")?; let mut client; if let (Ok(user), Ok(password)) = (env::var("TUSER"), env::var("TPWD")) { client = TransClient::with_auth(url.parse()?, BasicAuth { user, password }); } else { client = TransClient::new(url.parse()?); } info!("Client is ready!"); let add: TorrentAddArgs = TorrentAddArgs { filename: Some( "https://releases.ubuntu.com/jammy/ubuntu-22.04.1-desktop-amd64.iso.torrentt" .to_string(), ), ..TorrentAddArgs::default() }; match client.torrent_add(add).await { Ok(res) => { println!("Add result: {:?}", &res.is_ok()); println!("response: {:?}", &res); assert!(!&res.is_ok()); } Err(e) => { println!("Error: {:#?}", e); } } Ok(()) } }