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
76pub struct Qbit {
79 client: Client,
80 endpoint: Url,
81 state: Mutex<LoginState>,
82}
83
84impl Qbit {
85 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 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 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#[derive(Debug, thiserror::Error)]
247pub enum ApiError {
248 #[error("Invalid credentials")]
250 BadCredentials,
251
252 #[error("User's IP is banned for too many failed login attempts")]
254 IpBanned,
255
256 #[error("API routes requires login, try again")]
258 NotLoggedIn,
259
260 #[error("Torrent not found")]
262 TorrentNotFound,
263
264 #[error("Torrent name is empty")]
266 TorrentNameEmpty,
267
268 #[error("Torrent file is not valid")]
270 TorrentFileInvalid,
271
272 #[error("Torrent could not be added")]
274 TorrentAddFailed,
275
276 #[error("`newUrl` is not a valid URL")]
278 InvalidTrackerUrl,
279
280 #[error("`newUrl` already exists for the torrent or `origUrl` was not found")]
282 ConflictTrackerUrl,
283
284 #[error("None of the given peers are valid")]
286 InvalidPeers,
287
288 #[error("Torrent queueing is not enabled")]
290 QueueingDisabled,
291
292 #[error("Torrent metadata hasn't downloaded yet or at least one file id was not found")]
295 MetaNotDownloadedOrIdNotFound,
296
297 #[error("Save path is empty")]
299 SavePathEmpty,
300
301 #[error("User does not have write access to the directory")]
303 NoWriteAccess,
304
305 #[error("Unable to create save path directory")]
307 UnableToCreateDir,
308
309 #[error("Category name does not exist")]
311 CategoryNotFound,
312
313 #[error("Category editing failed")]
315 CategoryEditingFailed,
316
317 #[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;