Skip to main content

qbit_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::{
6    fmt::Debug,
7    sync::{Mutex, MutexGuard},
8};
9
10mod client;
11use client::*;
12
13pub mod model;
14pub use builder::QbitBuilder;
15use serde::Serialize;
16use tap::TapFallible;
17use tracing::{trace, warn};
18use url::Url;
19
20use crate::{ext::*, model::Credential};
21
22mod builder;
23mod endpoint;
24mod ext;
25
26#[derive(Clone, Debug)]
27enum LoginState {
28    CookieProvided {
29        cookie: String,
30    },
31    ApiKeyProvided {
32        api_key: String,
33    },
34    NotLoggedIn {
35        credential: Credential,
36    },
37    LoggedIn {
38        cookie: String,
39        credential: Credential,
40    },
41}
42
43impl LoginState {
44    fn as_header(&self) -> (Option<header::HeaderName>, Option<&str>) {
45        match self {
46            Self::CookieProvided { cookie } => (Some(header::COOKIE), Some(cookie)),
47            Self::ApiKeyProvided { api_key } => (Some(header::AUTHORIZATION), Some(api_key)),
48            Self::NotLoggedIn { .. } => (None, None),
49            Self::LoggedIn { cookie, .. } => (Some(header::COOKIE), Some(cookie)),
50        }
51    }
52
53    fn as_credential(&self) -> Option<&Credential> {
54        match self {
55            Self::CookieProvided { .. } => None,
56            Self::ApiKeyProvided { .. } => None,
57            Self::NotLoggedIn { credential } => Some(credential),
58            Self::LoggedIn { credential, .. } => Some(credential),
59        }
60    }
61
62    fn add_cookie(&mut self, cookie: String) {
63        match self {
64            Self::CookieProvided { .. } => {}
65            Self::ApiKeyProvided { .. } => {}
66            Self::LoggedIn { credential, .. } | Self::NotLoggedIn { credential } => {
67                *self = Self::LoggedIn {
68                    cookie,
69                    credential: credential.clone(),
70                };
71            }
72        }
73    }
74}
75
76/// Main entry point of the library. It provides a high-level API to interact
77/// with qBittorrent WebUI API.
78pub struct Qbit {
79    client: Client,
80    endpoint: Url,
81    state: Mutex<LoginState>,
82}
83
84impl Qbit {
85    /// Create a new [`QbitBuilder`] to build a [`Qbit`] instance.
86    pub fn builder() -> QbitBuilder {
87        QbitBuilder::new()
88    }
89
90    pub fn new_with_client<U>(endpoint: U, credential: Credential, client: Client) -> Self
91    where
92        U: TryInto<Url>,
93        U::Error: Debug,
94    {
95        Self::builder()
96            .endpoint(endpoint)
97            .credential(credential)
98            .client(client)
99            .build()
100    }
101
102    pub fn new<U>(endpoint: U, credential: Credential) -> Self
103    where
104        U: TryInto<Url>,
105        U::Error: Debug,
106    {
107        Self::new_with_client(endpoint, credential, Client::new())
108    }
109
110    fn url(&self, path: &'static str) -> Url {
111        self.endpoint
112            .join("api/v2/")
113            .unwrap()
114            .join(path)
115            .expect("Invalid API endpoint")
116    }
117
118    fn state(&self) -> MutexGuard<'_, LoginState> {
119        self.state.lock().unwrap()
120    }
121
122    async fn get(&self, path: &'static str) -> Result<Response> {
123        self.request(Method::GET, path, NONE).await
124    }
125
126    async fn get_with(
127        &self,
128        path: &'static str,
129        param: &(impl Serialize + Sync),
130    ) -> Result<Response> {
131        self.request(
132            Method::GET,
133            path,
134            Some(|req: RequestBuilder| req.query(param).check()),
135        )
136        .await
137    }
138
139    async fn post(&self, path: &'static str) -> Result<Response> {
140        self.request(Method::POST, path, NONE).await
141    }
142
143    async fn post_with(
144        &self,
145        path: &'static str,
146        body: &(impl Serialize + Sync),
147    ) -> Result<Response> {
148        self.request(
149            Method::POST,
150            path,
151            Some(|req: RequestBuilder| req.form(body).check()),
152        )
153        .await
154    }
155
156    async fn request(
157        &self,
158        method: Method,
159        path: &'static str,
160        mut map: Option<impl FnMut(RequestBuilder) -> Result<RequestBuilder>>,
161    ) -> Result<Response> {
162        for i in 0..3 {
163            // If it's not the first attempt, we need to re-login
164            self.login(i != 0).await?;
165
166            let (header_key, header_value) = {
167                let state = self.state();
168                let (header_key, header_value) = state.as_header();
169                let header_key = header_key.expect("Should always have header key if logged in");
170                let header_value =
171                    header_value.expect("Should always have header value if logged in");
172                (header_key.to_owned(), header_value.to_owned())
173            };
174
175            let mut req = self
176                .client
177                .request(method.clone(), self.url(path))
178                .check()?
179                .header(header_key, header_value)
180                .check()?;
181
182            if let Some(map) = map.as_mut() {
183                req = map(req)?;
184            }
185
186            trace!(request = ?req, "Sending request");
187
188            let res = req
189                .send()
190                .await?
191                .map_status(|code| match code as _ {
192                    StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
193                    _ => None,
194                })
195                .tap_ok(|response| trace!(?response));
196
197            match res {
198                Err(Error::ApiError(ApiError::NotLoggedIn)) => {
199                    // Retry
200                    warn!("Cookie is not valid, retrying");
201                }
202                Err(e) => return Err(e),
203                Ok(t) => return Ok(t),
204            }
205        }
206
207        Err(Error::ApiError(ApiError::NotLoggedIn))
208    }
209}
210
211impl Clone for Qbit {
212    fn clone(&self) -> Self {
213        let state = self.state.lock().unwrap().clone();
214        Self {
215            client: self.client.clone(),
216            endpoint: self.endpoint.clone(),
217            state: Mutex::new(state),
218        }
219    }
220}
221
222const NONE: Option<fn(RequestBuilder) -> Result<RequestBuilder>> = Option::None;
223
224#[derive(Debug, thiserror::Error)]
225pub enum Error {
226    #[error("Http error: {0}")]
227    HttpError(#[from] client::Error),
228
229    #[error("API Returned bad response: {explain}")]
230    BadResponse { explain: &'static str },
231
232    #[error("API returned unknown status code: {0}")]
233    UnknownHttpCode(StatusCode),
234
235    #[error("Non ASCII header")]
236    NonAsciiHeader,
237
238    #[error(transparent)]
239    ApiError(#[from] ApiError),
240
241    #[error("serde_json error: {0}")]
242    SerdeJsonError(#[from] serde_json::Error),
243}
244
245/// Errors defined and returned by the API
246#[derive(Debug, thiserror::Error)]
247pub enum ApiError {
248    /// API returned 401 - invalid credentials
249    #[error("Invalid credentials")]
250    BadCredentials,
251
252    /// User's IP is banned for too many failed login attempts
253    #[error("User's IP is banned for too many failed login attempts")]
254    IpBanned,
255
256    /// API routes requires login, try again
257    #[error("API routes requires login, try again")]
258    NotLoggedIn,
259
260    /// Torrent not found
261    #[error("Torrent not found")]
262    TorrentNotFound,
263
264    /// Torrent name is empty
265    #[error("Torrent name is empty")]
266    TorrentNameEmpty,
267
268    /// Torrent file is not valid
269    #[error("Torrent file is not valid")]
270    TorrentFileInvalid,
271
272    /// Torrent could not be added (e.g. duplicate)
273    #[error("Torrent could not be added")]
274    TorrentAddFailed,
275
276    /// `newUrl` is not a valid URL
277    #[error("`newUrl` is not a valid URL")]
278    InvalidTrackerUrl,
279
280    /// `newUrl` already exists for the torrent or `origUrl` was not found
281    #[error("`newUrl` already exists for the torrent or `origUrl` was not found")]
282    ConflictTrackerUrl,
283
284    /// None of the given peers are valid
285    #[error("None of the given peers are valid")]
286    InvalidPeers,
287
288    /// Torrent queueing is not enabled
289    #[error("Torrent queueing is not enabled")]
290    QueueingDisabled,
291
292    /// Torrent metadata hasn't downloaded yet or at least one file id was not
293    /// found
294    #[error("Torrent metadata hasn't downloaded yet or at least one file id was not found")]
295    MetaNotDownloadedOrIdNotFound,
296
297    /// Save path is empty
298    #[error("Save path is empty")]
299    SavePathEmpty,
300
301    /// User does not have write access to the directory
302    #[error("User does not have write access to the directory")]
303    NoWriteAccess,
304
305    /// Unable to create save path directory
306    #[error("Unable to create save path directory")]
307    UnableToCreateDir,
308
309    /// Category name does not exist
310    #[error("Category name does not exist")]
311    CategoryNotFound,
312
313    /// Category editing failed
314    #[error("Category editing failed")]
315    CategoryEditingFailed,
316
317    /// Invalid `newPath` or `oldPath`, or `newPath` already in use
318    #[error("Invalid `newPath` or `oldPath`, or `newPath` already in use")]
319    InvalidPath,
320}
321
322type Result<T, E = Error> = std::result::Result<T, E>;
323
324#[cfg(test)]
325mod test;