1#![doc = include_str!("../README.md")]
2#![warn(clippy::future_not_send)]
3#![cfg_attr(feature = "docs", feature(doc_cfg))]
4
5use std::{
6 borrow::Borrow,
7 collections::HashMap,
8 fmt::Debug,
9 path::{Path, PathBuf},
10 sync::{Mutex, MutexGuard},
11};
12
13pub mod model;
14pub use builder::QbitBuilder;
15use bytes::Bytes;
16use reqwest::{Client, Method, Response, StatusCode, header};
17use serde::Serialize;
18use serde_with::skip_serializing_none;
19use tap::{Pipe, TapFallible};
20use tracing::{debug, trace, warn};
21use url::Url;
22
23use crate::{ext::*, model::*};
24
25mod builder;
26mod ext;
27
28#[derive(Clone)]
29enum LoginState {
30 CookieProvided {
31 cookie: String,
32 },
33 NotLoggedIn {
34 credential: Credential,
35 },
36 LoggedIn {
37 cookie: String,
38 credential: Credential,
39 },
40}
41
42impl LoginState {
43 fn as_cookie(&self) -> Option<&str> {
44 match self {
45 Self::CookieProvided { cookie } => Some(cookie),
46 Self::NotLoggedIn { .. } => None,
47 Self::LoggedIn { cookie, .. } => Some(cookie),
48 }
49 }
50
51 fn as_credential(&self) -> Option<&Credential> {
52 match self {
53 Self::CookieProvided { .. } => None,
54 Self::NotLoggedIn { credential } => Some(credential),
55 Self::LoggedIn { credential, .. } => Some(credential),
56 }
57 }
58
59 fn add_cookie(&mut self, cookie: String) {
60 match self {
61 Self::CookieProvided { .. } => {}
62 Self::LoggedIn { credential, .. } | Self::NotLoggedIn { credential } => {
63 *self = Self::LoggedIn {
64 cookie,
65 credential: credential.clone(),
66 };
67 }
68 }
69 }
70}
71
72pub struct Qbit {
75 client: Client,
76 endpoint: Url,
77 state: Mutex<LoginState>,
78}
79
80impl Qbit {
81 pub fn builder() -> QbitBuilder {
83 QbitBuilder::new()
84 }
85
86 pub fn new_with_client<U>(endpoint: U, credential: Credential, client: Client) -> Self
87 where
88 U: TryInto<Url>,
89 U::Error: Debug,
90 {
91 Self::builder()
92 .endpoint(endpoint)
93 .credential(credential)
94 .client(client)
95 .build()
96 }
97
98 pub fn new<U>(endpoint: U, credential: Credential) -> Self
99 where
100 U: TryInto<Url>,
101 U::Error: Debug,
102 {
103 Self::new_with_client(endpoint, credential, Client::new())
104 }
105
106 #[deprecated = "Use `QbitBuilder::cookie` instead"]
107 pub fn with_cookie(self, cookie: impl Into<String>) -> Self {
108 Self {
109 state: Mutex::from(LoginState::CookieProvided {
110 cookie: cookie.into(),
111 }),
112 ..self
113 }
114 }
115
116 pub async fn get_cookie(&self) -> Option<String> {
117 self.state
118 .lock()
119 .unwrap()
120 .as_cookie()
121 .map(ToOwned::to_owned)
122 }
123
124 pub async fn logout(&self) -> Result<()> {
125 self.get("auth/logout").await?.end()
126 }
127
128 pub async fn get_version(&self) -> Result<String> {
129 self.get("app/version")
130 .await?
131 .text()
132 .await
133 .map_err(Into::into)
134 }
135
136 pub async fn get_webapi_version(&self) -> Result<String> {
137 self.get("app/webapiVersion")
138 .await?
139 .text()
140 .await
141 .map_err(Into::into)
142 }
143
144 pub async fn get_build_info(&self) -> Result<BuildInfo> {
145 self.get("app/buildInfo")
146 .await?
147 .json()
148 .await
149 .map_err(Into::into)
150 }
151
152 pub async fn shutdown(&self) -> Result<()> {
153 self.post("app/shutdown", NONE).await?.end()
154 }
155
156 pub async fn get_preferences(&self) -> Result<Preferences> {
157 self.get("app/preferences")
158 .await?
159 .json()
160 .await
161 .map_err(Into::into)
162 }
163
164 pub async fn set_preferences(
165 &self,
166 preferences: impl Borrow<Preferences> + Send + Sync,
167 ) -> Result<()> {
168 #[derive(Serialize)]
169 struct Arg {
170 json: String,
171 }
172
173 self.post(
174 "app/setPreferences",
175 Some(&Arg {
176 json: serde_json::to_string(preferences.borrow())?,
177 }),
178 )
179 .await?
180 .end()
181 }
182
183 pub async fn get_default_save_path(&self) -> Result<PathBuf> {
184 self.get("app/defaultSavePath")
185 .await?
186 .text()
187 .await
188 .map_err(Into::into)
189 .map(PathBuf::from)
190 }
191
192 pub async fn get_logs(&self, arg: impl Borrow<GetLogsArg> + Send + Sync) -> Result<Vec<Log>> {
193 self.get_with("log/main", arg.borrow())
194 .await?
195 .json()
196 .await
197 .map_err(Into::into)
198 }
199
200 pub async fn get_peer_logs(
201 &self,
202 last_known_id: impl Into<Option<i64>> + Send + Sync,
203 ) -> Result<Vec<PeerLog>> {
204 #[derive(Serialize)]
205 #[skip_serializing_none]
206 struct Arg {
207 last_known_id: Option<i64>,
208 }
209
210 self.get_with(
211 "log/peers",
212 &Arg {
213 last_known_id: last_known_id.into(),
214 },
215 )
216 .await?
217 .json()
218 .await
219 .map_err(Into::into)
220 }
221
222 pub async fn sync(&self, rid: impl Into<Option<i64>> + Send + Sync) -> Result<SyncData> {
223 #[derive(Serialize)]
224 #[skip_serializing_none]
225 struct Arg {
226 rid: Option<i64>,
227 }
228
229 self.get_with("sync/maindata", &Arg { rid: rid.into() })
230 .await?
231 .json()
232 .await
233 .map_err(Into::into)
234 }
235
236 pub async fn get_torrent_peers(
237 &self,
238 hash: impl AsRef<str> + Send + Sync,
239 rid: impl Into<Option<i64>> + Send + Sync,
240 ) -> Result<PeerSyncData> {
241 #[derive(Serialize)]
242 struct Arg<'a> {
243 hash: &'a str,
244 rid: Option<i64>,
245 }
246
247 self.get_with(
248 "sync/torrentPeers",
249 &Arg {
250 hash: hash.as_ref(),
251 rid: rid.into(),
252 },
253 )
254 .await
255 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
256 .json()
257 .await
258 .map_err(Into::into)
259 }
260
261 pub async fn get_transfer_info(&self) -> Result<TransferInfo> {
262 self.get("transfer/info")
263 .await?
264 .json()
265 .await
266 .map_err(Into::into)
267 }
268
269 pub async fn get_speed_limits_mode(&self) -> Result<bool> {
270 self.get("transfer/speedLimitsMode")
271 .await?
272 .text()
273 .await
274 .map_err(Into::into)
275 .and_then(|s| match s.as_str() {
276 "0" => Ok(false),
277 "1" => Ok(true),
278 _ => Err(Error::BadResponse {
279 explain: "Received non-number response body on `transfer/speedLimitsMode`",
280 }),
281 })
282 }
283
284 pub async fn toggle_speed_limits_mode(&self) -> Result<()> {
285 self.post("transfer/toggleSpeedLimitsMode", None::<&()>)
286 .await?
287 .end()
288 }
289
290 pub async fn get_download_limit(&self) -> Result<u64> {
291 self.get("transfer/downloadLimit")
292 .await?
293 .text()
294 .await
295 .map_err(Into::into)
296 .and_then(|s| {
297 s.parse().map_err(|_| Error::BadResponse {
298 explain: "Received non-number response body on `transfer/downloadLimit`",
299 })
300 })
301 }
302
303 pub async fn set_download_limit(&self, limit: u64) -> Result<()> {
304 #[derive(Serialize)]
305 struct Arg {
306 limit: u64,
307 }
308
309 self.post("transfer/setDownloadLimit", Some(&Arg { limit }))
310 .await?
311 .end()
312 }
313
314 pub async fn get_upload_limit(&self) -> Result<u64> {
315 self.get("transfer/uploadLimit")
316 .await?
317 .text()
318 .await
319 .map_err(Into::into)
320 .and_then(|s| {
321 s.parse().map_err(|_| Error::BadResponse {
322 explain: "Received non-number response body on `transfer/uploadLimit`",
323 })
324 })
325 }
326
327 pub async fn set_upload_limit(&self, limit: u64) -> Result<()> {
328 #[derive(Serialize)]
329 struct Arg {
330 limit: u64,
331 }
332
333 self.post("transfer/setUploadLimit", Some(&Arg { limit }))
334 .await?
335 .end()
336 }
337
338 pub async fn ban_peers(&self, peers: impl Into<Sep<String, '|'>> + Send + Sync) -> Result<()> {
339 #[derive(Serialize)]
340 struct Arg {
341 peers: String,
342 }
343
344 self.post(
345 "transfer/banPeers",
346 Some(&Arg {
347 peers: peers.into().to_string(),
348 }),
349 )
350 .await?
351 .end()
352 }
353
354 pub async fn get_torrent_list(&self, arg: GetTorrentListArg) -> Result<Vec<Torrent>> {
355 self.get_with("torrents/info", &arg)
356 .await?
357 .json()
358 .await
359 .map_err(Into::into)
360 }
361
362 pub async fn export_torrent(&self, hash: impl AsRef<str> + Send + Sync) -> Result<Bytes> {
363 self.get_with("torrents/export", &HashArg::new(hash.as_ref()))
364 .await?
365 .bytes()
366 .await
367 .map_err(Into::into)
368 }
369
370 pub async fn get_torrent_properties(
371 &self,
372 hash: impl AsRef<str> + Send + Sync,
373 ) -> Result<TorrentProperty> {
374 self.get_with("torrents/properties", &HashArg::new(hash.as_ref()))
375 .await
376 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
377 .json()
378 .await
379 .map_err(Into::into)
380 }
381
382 pub async fn get_torrent_trackers(
383 &self,
384 hash: impl AsRef<str> + Send + Sync,
385 ) -> Result<Vec<Tracker>> {
386 self.get_with("torrents/trackers", &HashArg::new(hash.as_ref()))
387 .await
388 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
389 .json()
390 .await
391 .map_err(Into::into)
392 }
393
394 pub async fn get_torrent_web_seeds(
395 &self,
396 hash: impl AsRef<str> + Send + Sync,
397 ) -> Result<Vec<WebSeed>> {
398 self.get_with("torrents/webseeds", &HashArg::new(hash.as_ref()))
399 .await
400 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
401 .json()
402 .await
403 .map_err(Into::into)
404 }
405
406 pub async fn get_torrent_contents(
407 &self,
408 hash: impl AsRef<str> + Send + Sync,
409 indexes: impl Into<Option<Sep<String, '|'>>> + Send + Sync,
410 ) -> Result<Vec<TorrentContent>> {
411 #[derive(Serialize)]
412 struct Arg<'a> {
413 hash: &'a str,
414 #[serde(skip_serializing_if = "Option::is_none")]
415 indexes: Option<String>,
416 }
417
418 self.get_with(
419 "torrents/files",
420 &Arg {
421 hash: hash.as_ref(),
422 indexes: indexes.into().map(|s| s.to_string()),
423 },
424 )
425 .await
426 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
427 .json()
428 .await
429 .map_err(Into::into)
430 }
431
432 pub async fn get_torrent_pieces_states(
433 &self,
434 hash: impl AsRef<str> + Send + Sync,
435 ) -> Result<Vec<PieceState>> {
436 self.get_with("torrents/pieceStates", &HashArg::new(hash.as_ref()))
437 .await
438 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
439 .json()
440 .await
441 .map_err(Into::into)
442 }
443
444 pub async fn get_torrent_pieces_hashes(
445 &self,
446 hash: impl AsRef<str> + Send + Sync,
447 ) -> Result<Vec<String>> {
448 self.get_with("torrents/pieceHashes", &HashArg::new(hash.as_ref()))
449 .await
450 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
451 .json()
452 .await
453 .map_err(Into::into)
454 }
455
456 pub async fn stop_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
457 self.post("torrents/stop", Some(&HashesArg::new(hashes)))
458 .await?
459 .end()
460 }
461
462 pub async fn start_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
463 self.post("torrents/start", Some(&HashesArg::new(hashes)))
464 .await?
465 .end()
466 }
467
468 pub async fn delete_torrents(
469 &self,
470 hashes: impl Into<Hashes> + Send + Sync,
471 delete_files: impl Into<Option<bool>> + Send + Sync,
472 ) -> Result<()> {
473 #[derive(Serialize)]
474 #[skip_serializing_none]
475 #[serde(rename_all = "camelCase")]
476 struct Arg {
477 hashes: Hashes,
478 delete_files: Option<bool>,
479 }
480 self.post(
481 "torrents/delete",
482 Some(&Arg {
483 hashes: hashes.into(),
484 delete_files: delete_files.into(),
485 }),
486 )
487 .await?
488 .end()
489 }
490
491 pub async fn recheck_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
492 self.post("torrents/recheck", Some(&HashesArg::new(hashes)))
493 .await?
494 .end()
495 }
496
497 pub async fn reannounce_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
498 self.post("torrents/reannounce", Some(&HashesArg::new(hashes)))
499 .await?
500 .end()
501 }
502
503 pub async fn add_torrent(&self, arg: impl Borrow<AddTorrentArg> + Send + Sync) -> Result<()> {
504 let a: &AddTorrentArg = arg.borrow();
505 match &a.source {
506 TorrentSource::Urls { urls: _ } => {
507 self.post("torrents/add", Some(arg.borrow())).await?.end()
508 }
509 TorrentSource::TorrentFiles { torrents } => {
510 for i in 0..3 {
511 self.login(i != 0).await?;
513 let form = torrents.iter().fold(
515 serde_json::to_value(a)?
516 .as_object()
517 .unwrap()
518 .into_iter()
519 .fold(reqwest::multipart::Form::new(), |form, (k, v)| {
520 let v = match v.as_str() {
524 Some(v_str) => v_str.to_string(),
525 None => v.to_string(),
526 };
527 form.text(k.to_string(), v.to_string())
528 }),
529 |mut form, torrent| {
530 let p = reqwest::multipart::Part::bytes(torrent.data.clone())
531 .file_name(torrent.filename.to_string())
532 .mime_str("application/x-bittorrent")
533 .unwrap();
534 form = form.part("torrents", p);
535 form
536 },
537 );
538 let req = self
539 .client
540 .request(Method::POST, self.url("torrents/add"))
541 .multipart(form)
542 .header(header::COOKIE, {
543 self.state()
544 .as_cookie()
545 .expect("Cookie should be set after login")
546 });
547
548 trace!(request = ?req, "Sending request");
549 let res = req
550 .send()
551 .await?
552 .map_status(|code| match code as _ {
553 StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
554 _ => None,
555 })
556 .tap_ok(|response| trace!(?response));
557
558 match res {
559 Err(Error::ApiError(ApiError::NotLoggedIn)) => {
560 warn!("Cookie is not valid, retrying");
562 }
563 Err(e) => return Err(e),
564 Ok(t) => return t.end(),
565 }
566 }
567
568 Err(Error::ApiError(ApiError::NotLoggedIn))
569 }
570 }
571 }
572
573 pub async fn add_trackers(
574 &self,
575 hash: impl AsRef<str> + Send + Sync,
576 urls: impl Into<Sep<String, '\n'>> + Send + Sync,
577 ) -> Result<()> {
578 #[derive(Serialize)]
579 struct Arg<'a> {
580 hash: &'a str,
581 urls: String,
582 }
583
584 self.post(
585 "torrents/addTrackers",
586 Some(&Arg {
587 hash: hash.as_ref(),
588 urls: urls.into().to_string(),
589 }),
590 )
591 .await
592 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
593 .json()
594 .await
595 .map_err(Into::into)
596 }
597
598 pub async fn edit_trackers(
599 &self,
600 hash: impl AsRef<str> + Send + Sync,
601 orig_url: Url,
602 new_url: Url,
603 ) -> Result<()> {
604 #[derive(Serialize)]
605 #[serde(rename_all = "camelCase")]
606 struct EditTrackerArg<'a> {
607 hash: &'a str,
608 orig_url: Url,
609 new_url: Url,
610 }
611 self.post(
612 "torrents/editTracker",
613 Some(&EditTrackerArg {
614 hash: hash.as_ref(),
615 orig_url,
616 new_url,
617 }),
618 )
619 .await?
620 .map_status(|c| match c {
621 StatusCode::BAD_REQUEST => Some(Error::ApiError(ApiError::InvalidTrackerUrl)),
622 StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
623 StatusCode::CONFLICT => Some(Error::ApiError(ApiError::ConflictTrackerUrl)),
624 _ => None,
625 })?
626 .end()
627 }
628
629 pub async fn remove_trackers(
630 &self,
631 hash: impl AsRef<str> + Send + Sync,
632 urls: impl Into<Sep<Url, '|'>> + Send + Sync,
633 ) -> Result<()> {
634 #[derive(Serialize)]
635 struct Arg<'a> {
636 hash: &'a str,
637 urls: Sep<Url, '|'>,
638 }
639
640 self.post(
641 "torrents/removeTrackers",
642 Some(&Arg {
643 hash: hash.as_ref(),
644 urls: urls.into(),
645 }),
646 )
647 .await
648 .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
649 .end()
650 }
651
652 pub async fn add_peers(
653 &self,
654 hashes: impl Into<Hashes> + Send + Sync,
655 peers: impl Into<Sep<String, '|'>> + Send + Sync,
656 ) -> Result<()> {
657 #[derive(Serialize)]
658 struct AddPeersArg {
659 hash: String,
660 peers: Sep<String, '|'>,
661 }
662
663 self.post(
664 "torrents/addPeers",
665 Some(&AddPeersArg {
666 hash: hashes.into().to_string(),
667 peers: peers.into(),
668 }),
669 )
670 .await
671 .and_then(|r| {
672 r.map_status(|c| {
673 if c == StatusCode::BAD_REQUEST {
674 Some(Error::ApiError(ApiError::InvalidPeers))
675 } else {
676 None
677 }
678 })
679 })?
680 .end()
681 }
682
683 pub async fn increase_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
684 self.post("torrents/increasePrio", Some(&HashesArg::new(hashes)))
685 .await?
686 .map_status(|c| {
687 if c == StatusCode::CONFLICT {
688 Some(Error::ApiError(ApiError::QueueingDisabled))
689 } else {
690 None
691 }
692 })?;
693 Ok(())
694 }
695
696 pub async fn decrease_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
697 self.post("torrents/decreasePrio", Some(&HashesArg::new(hashes)))
698 .await?
699 .map_status(|c| {
700 if c == StatusCode::CONFLICT {
701 Some(Error::ApiError(ApiError::QueueingDisabled))
702 } else {
703 None
704 }
705 })?;
706 Ok(())
707 }
708
709 pub async fn maximal_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
710 self.post("torrents/topPrio", Some(&HashesArg::new(hashes)))
711 .await?
712 .map_status(|c| {
713 if c == StatusCode::CONFLICT {
714 Some(Error::ApiError(ApiError::QueueingDisabled))
715 } else {
716 None
717 }
718 })?;
719 Ok(())
720 }
721
722 pub async fn minimal_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
723 self.post("torrents/bottomPrio", Some(&HashesArg::new(hashes)))
724 .await?
725 .map_status(|c| {
726 if c == StatusCode::CONFLICT {
727 Some(Error::ApiError(ApiError::QueueingDisabled))
728 } else {
729 None
730 }
731 })?;
732 Ok(())
733 }
734
735 pub async fn set_file_priority(
736 &self,
737 hash: impl AsRef<str> + Send + Sync,
738 indexes: impl Into<Sep<i64, '|'>> + Send + Sync,
739 priority: Priority,
740 ) -> Result<()> {
741 #[derive(Serialize)]
742 struct SetFilePriorityArg<'a> {
743 hash: &'a str,
744 id: Sep<i64, '|'>,
745 priority: Priority,
746 }
747
748 self.post(
749 "torrents/filePrio",
750 Some(&SetFilePriorityArg {
751 hash: hash.as_ref(),
752 id: indexes.into(),
753 priority,
754 }),
755 )
756 .await?
757 .map_status(|c| match c {
758 StatusCode::BAD_REQUEST => panic!("Invalid priority or id. This is a bug."),
759 StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
760 StatusCode::CONFLICT => Some(Error::ApiError(ApiError::MetaNotDownloadedOrIdNotFound)),
761 _ => None,
762 })?;
763 Ok(())
764 }
765
766 pub async fn get_torrent_download_limit(
767 &self,
768 hashes: impl Into<Hashes> + Send + Sync,
769 ) -> Result<HashMap<String, u64>> {
770 self.get_with("torrents/downloadLimit", &HashesArg::new(hashes))
771 .await?
772 .json()
773 .await
774 .map_err(Into::into)
775 }
776
777 pub async fn set_torrent_download_limit(
778 &self,
779 hashes: impl Into<Hashes> + Send + Sync,
780 limit: u64,
781 ) -> Result<()> {
782 #[derive(Serialize)]
783 struct Arg {
784 hashes: String,
785 limit: u64,
786 }
787
788 self.post(
789 "torrents/downloadLimit",
790 Some(&Arg {
791 hashes: hashes.into().to_string(),
792 limit,
793 }),
794 )
795 .await?
796 .end()
797 }
798
799 pub async fn set_torrent_shared_limit(
800 &self,
801 arg: impl Borrow<SetTorrentSharedLimitArg> + Send + Sync,
802 ) -> Result<()> {
803 self.post("torrents/setShareLimits", Some(arg.borrow()))
804 .await?
805 .end()
806 }
807
808 pub async fn get_torrent_upload_limit(
809 &self,
810 hashes: impl Into<Hashes> + Send + Sync,
811 ) -> Result<HashMap<String, u64>> {
812 self.get_with("torrents/uploadLimit", &HashesArg::new(hashes))
813 .await?
814 .json()
815 .await
816 .map_err(Into::into)
817 }
818
819 pub async fn set_torrent_upload_limit(
820 &self,
821 hashes: impl Into<Hashes> + Send + Sync,
822 limit: u64,
823 ) -> Result<()> {
824 #[derive(Serialize)]
825 struct Arg {
826 hashes: String,
827 limit: u64,
828 }
829
830 self.post(
831 "torrents/uploadLimit",
832 Some(&Arg {
833 hashes: hashes.into().to_string(),
834 limit,
835 }),
836 )
837 .await?
838 .end()
839 }
840
841 pub async fn set_torrent_location(
842 &self,
843 hashes: impl Into<Hashes> + Send + Sync,
844 location: impl AsRef<Path> + Send + Sync,
845 ) -> Result<()> {
846 #[derive(Serialize)]
847 struct Arg<'a> {
848 hashes: String,
849 location: &'a Path,
850 }
851
852 self.post(
853 "torrents/setLocation",
854 Some(&Arg {
855 hashes: hashes.into().to_string(),
856 location: location.as_ref(),
857 }),
858 )
859 .await?
860 .map_status(|c| match c {
861 StatusCode::BAD_REQUEST => Some(Error::ApiError(ApiError::SavePathEmpty)),
862 StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NoWriteAccess)),
863 StatusCode::CONFLICT => Some(Error::ApiError(ApiError::UnableToCreateDir)),
864 _ => None,
865 })?
866 .end()
867 }
868
869 pub async fn set_torrent_name<T: AsRef<str> + Send + Sync>(
870 &self,
871 hash: impl AsRef<str> + Send + Sync,
872 name: NonEmptyStr<T>,
873 ) -> Result<()> {
874 #[derive(Serialize)]
875 struct RenameArg<'a> {
876 hash: &'a str,
877 name: &'a str,
878 }
879
880 self.post(
881 "torrents/rename",
882 Some(&RenameArg {
883 hash: hash.as_ref(),
884 name: name.as_str(),
885 }),
886 )
887 .await?
888 .map_status(|c| match c {
889 StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
890 StatusCode::CONFLICT => panic!("Name should not be empty. This is a bug."),
891 _ => None,
892 })?
893 .end()
894 }
895
896 pub async fn set_torrent_category(
897 &self,
898 hashes: impl Into<Hashes> + Send + Sync,
899 category: impl AsRef<str> + Send + Sync,
900 ) -> Result<()> {
901 #[derive(Serialize)]
902 struct Arg<'a> {
903 hashes: String,
904 category: &'a str,
905 }
906
907 self.post(
908 "torrents/setCategory",
909 Some(&Arg {
910 hashes: hashes.into().to_string(),
911 category: category.as_ref(),
912 }),
913 )
914 .await?
915 .map_status(|c| {
916 if c == StatusCode::CONFLICT {
917 Some(Error::ApiError(ApiError::CategoryNotFound))
918 } else {
919 None
920 }
921 })?
922 .end()
923 }
924
925 pub async fn get_categories(&self) -> Result<HashMap<String, Category>> {
926 self.get("torrents/categories")
927 .await?
928 .json()
929 .await
930 .map_err(Into::into)
931 }
932
933 pub async fn add_category<T: AsRef<str> + Send + Sync>(
934 &self,
935 category: NonEmptyStr<T>,
936 save_path: impl AsRef<Path> + Send + Sync,
937 ) -> Result<()> {
938 #[derive(Serialize)]
939 #[serde(rename_all = "camelCase")]
940 struct Arg<'a> {
941 category: &'a str,
942 save_path: &'a Path,
943 }
944
945 self.post(
946 "torrents/createCategory",
947 Some(&Arg {
948 category: category.as_str(),
949 save_path: save_path.as_ref(),
950 }),
951 )
952 .await?
953 .end()
954 }
955
956 pub async fn edit_category<T: AsRef<str> + Send + Sync>(
957 &self,
958 category: NonEmptyStr<T>,
959 save_path: impl AsRef<Path> + Send + Sync,
960 ) -> Result<()> {
961 #[derive(Serialize)]
962 #[serde(rename_all = "camelCase")]
963 struct Arg<'a> {
964 category: &'a str,
965 save_path: &'a Path,
966 }
967
968 self.post(
969 "torrents/createCategory",
970 Some(&Arg {
971 category: category.as_str(),
972 save_path: save_path.as_ref(),
973 }),
974 )
975 .await?
976 .map_status(|c| {
977 if c == StatusCode::CONFLICT {
978 Some(Error::ApiError(ApiError::CategoryEditingFailed))
979 } else {
980 None
981 }
982 })?
983 .end()
984 }
985
986 pub async fn remove_categories(
987 &self,
988 categories: impl Into<Sep<String, '\n'>> + Send + Sync,
989 ) -> Result<()> {
990 #[derive(Serialize)]
991 struct Arg<'a> {
992 categories: &'a str,
993 }
994
995 self.post(
996 "torrents/removeCategories",
997 Some(&Arg {
998 categories: &categories.into().to_string(),
999 }),
1000 )
1001 .await?
1002 .end()
1003 }
1004
1005 pub async fn add_torrent_tags(
1006 &self,
1007 hashes: impl Into<Hashes> + Send + Sync,
1008 tags: impl Into<Sep<String, '\n'>> + Send + Sync,
1009 ) -> Result<()> {
1010 #[derive(Serialize)]
1011 struct Arg<'a> {
1012 hashes: String,
1013 tags: &'a str,
1014 }
1015
1016 self.post(
1017 "torrents/addTags",
1018 Some(&Arg {
1019 hashes: hashes.into().to_string(),
1020 tags: &tags.into().to_string(),
1021 }),
1022 )
1023 .await?
1024 .end()
1025 }
1026
1027 pub async fn remove_torrent_tags(
1028 &self,
1029 hashes: impl Into<Hashes> + Send + Sync,
1030 tags: Option<impl Into<Sep<String, ','>> + Send>,
1031 ) -> Result<()> {
1032 #[derive(Serialize)]
1033 #[skip_serializing_none]
1034 struct Arg {
1035 hashes: String,
1036 tags: Option<String>,
1037 }
1038
1039 self.post(
1040 "torrents/removeTags",
1041 Some(&Arg {
1042 hashes: hashes.into().to_string(),
1043 tags: tags.map(|t| t.into().to_string()),
1044 }),
1045 )
1046 .await?
1047 .end()
1048 }
1049
1050 pub async fn get_all_tags(&self) -> Result<Vec<String>> {
1051 self.get("torrents/tags")
1052 .await?
1053 .json()
1054 .await
1055 .map_err(Into::into)
1056 }
1057
1058 pub async fn create_tags(&self, tags: impl Into<Sep<String, ','>> + Send + Sync) -> Result<()> {
1059 #[derive(Serialize)]
1060 struct Arg {
1061 tags: String,
1062 }
1063
1064 self.post(
1065 "torrents/createTags",
1066 Some(&Arg {
1067 tags: tags.into().to_string(),
1068 }),
1069 )
1070 .await?
1071 .end()
1072 }
1073
1074 pub async fn delete_tags(&self, tags: impl Into<Sep<String, ','>> + Send + Sync) -> Result<()> {
1075 #[derive(Serialize)]
1076 struct Arg {
1077 tags: String,
1078 }
1079
1080 self.post(
1081 "torrents/deleteTags",
1082 Some(&Arg {
1083 tags: tags.into().to_string(),
1084 }),
1085 )
1086 .await?
1087 .end()
1088 }
1089
1090 pub async fn set_auto_management(
1091 &self,
1092 hashes: impl Into<Hashes> + Send + Sync,
1093 enable: bool,
1094 ) -> Result<()> {
1095 #[derive(Serialize)]
1096 struct Arg {
1097 hashes: String,
1098 enable: bool,
1099 }
1100
1101 self.post(
1102 "torrents/setAutoManagement",
1103 Some(&Arg {
1104 hashes: hashes.into().to_string(),
1105 enable,
1106 }),
1107 )
1108 .await?
1109 .end()
1110 }
1111
1112 pub async fn toggle_sequential_download(
1113 &self,
1114 hashes: impl Into<Hashes> + Send + Sync,
1115 ) -> Result<()> {
1116 self.post(
1117 "torrents/toggleSequentialDownload",
1118 Some(&HashesArg::new(hashes)),
1119 )
1120 .await?
1121 .end()
1122 }
1123
1124 pub async fn toggle_first_last_piece_priority(
1125 &self,
1126 hashes: impl Into<Hashes> + Send + Sync,
1127 ) -> Result<()> {
1128 self.post(
1129 "torrents/toggleFirstLastPiecePrio",
1130 Some(&HashesArg::new(hashes)),
1131 )
1132 .await?
1133 .end()
1134 }
1135
1136 pub async fn set_force_start(
1137 &self,
1138 hashes: impl Into<Hashes> + Send + Sync,
1139 value: bool,
1140 ) -> Result<()> {
1141 #[derive(Serialize)]
1142 struct Arg {
1143 hashes: String,
1144 value: bool,
1145 }
1146
1147 self.post(
1148 "torrents/setForceStart",
1149 Some(&Arg {
1150 hashes: hashes.into().to_string(),
1151 value,
1152 }),
1153 )
1154 .await?
1155 .end()
1156 }
1157
1158 pub async fn set_super_seeding(
1159 &self,
1160 hashes: impl Into<Hashes> + Send + Sync,
1161 value: bool,
1162 ) -> Result<()> {
1163 #[derive(Serialize)]
1164 struct Arg {
1165 hashes: String,
1166 value: bool,
1167 }
1168
1169 self.post(
1170 "torrents/setSuperSeeding",
1171 Some(&Arg {
1172 hashes: hashes.into().to_string(),
1173 value,
1174 }),
1175 )
1176 .await?
1177 .end()
1178 }
1179
1180 pub async fn rename_file(
1181 &self,
1182 hash: impl AsRef<str> + Send + Sync,
1183 old_path: impl AsRef<Path> + Send + Sync,
1184 new_path: impl AsRef<Path> + Send + Sync,
1185 ) -> Result<()> {
1186 #[derive(Serialize)]
1187 #[serde(rename_all = "camelCase")]
1188 struct Arg<'a> {
1189 hash: &'a str,
1190 old_path: &'a Path,
1191 new_path: &'a Path,
1192 }
1193
1194 self.post(
1195 "torrents/renameFile",
1196 Some(&Arg {
1197 hash: hash.as_ref(),
1198 old_path: old_path.as_ref(),
1199 new_path: new_path.as_ref(),
1200 }),
1201 )
1202 .await?
1203 .map_status(|c| {
1204 if c == StatusCode::CONFLICT {
1205 Error::ApiError(ApiError::InvalidPath).pipe(Some)
1206 } else {
1207 None
1208 }
1209 })?
1210 .end()
1211 }
1212
1213 pub async fn rename_folder(
1214 &self,
1215 hash: impl AsRef<str> + Send + Sync,
1216 old_path: impl AsRef<Path> + Send + Sync,
1217 new_path: impl AsRef<Path> + Send + Sync,
1218 ) -> Result<()> {
1219 #[derive(Serialize)]
1220 #[serde(rename_all = "camelCase")]
1221 struct Arg<'a> {
1222 hash: &'a str,
1223 old_path: &'a Path,
1224 new_path: &'a Path,
1225 }
1226
1227 self.post(
1228 "torrents/renameFolder",
1229 Some(&Arg {
1230 hash: hash.as_ref(),
1231 old_path: old_path.as_ref(),
1232 new_path: new_path.as_ref(),
1233 }),
1234 )
1235 .await?
1236 .map_status(|c| {
1237 if c == StatusCode::CONFLICT {
1238 Error::ApiError(ApiError::InvalidPath).pipe(Some)
1239 } else {
1240 None
1241 }
1242 })?
1243 .end()
1244 }
1245
1246 pub async fn add_folder<T: AsRef<str> + Send + Sync>(&self, path: T) -> Result<()> {
1247 #[derive(Serialize)]
1248 struct Arg<'a> {
1249 path: &'a str,
1250 }
1251
1252 self.post(
1253 "rss/addFolder",
1254 Some(&Arg {
1255 path: path.as_ref(),
1256 }),
1257 )
1258 .await?
1259 .end()
1260 }
1261
1262 pub async fn add_feed<T: AsRef<str> + Send + Sync>(
1263 &self,
1264 url: T,
1265 path: Option<T>,
1266 ) -> Result<()> {
1267 #[derive(Serialize)]
1268 struct Arg<'a> {
1269 url: &'a str,
1270 path: Option<&'a str>,
1271 }
1272
1273 self.post(
1274 "rss/addFeed",
1275 Some(&Arg {
1276 url: url.as_ref(),
1277 path: path.as_ref().map(AsRef::as_ref),
1278 }),
1279 )
1280 .await?
1281 .end()
1282 }
1283
1284 pub async fn remove_item<T: AsRef<str> + Send + Sync>(&self, path: T) -> Result<()> {
1285 #[derive(Serialize)]
1286 struct Arg<'a> {
1287 path: &'a str,
1288 }
1289
1290 self.post(
1291 "rss/removeItem",
1292 Some(&Arg {
1293 path: path.as_ref(),
1294 }),
1295 )
1296 .await?
1297 .end()
1298 }
1299
1300 pub async fn move_item<T: AsRef<str> + Send + Sync>(
1301 &self,
1302 item_path: T,
1303 dest_path: T,
1304 ) -> Result<()> {
1305 #[derive(Serialize)]
1306 #[serde(rename_all = "camelCase")]
1307 struct Arg<'a> {
1308 item_path: &'a str,
1309 dest_path: &'a str,
1310 }
1311
1312 self.post(
1313 "rss/moveItem",
1314 Some(&Arg {
1315 item_path: item_path.as_ref(),
1316 dest_path: dest_path.as_ref(),
1317 }),
1318 )
1319 .await?
1320 .end()
1321 }
1322
1323 pub async fn mark_as_read<T: AsRef<str> + Send + Sync>(
1324 &self,
1325 item_path: T,
1326 article_id: Option<T>,
1327 ) -> Result<()> {
1328 #[derive(Serialize)]
1329 #[serde(rename_all = "camelCase")]
1330 struct Arg<'a> {
1331 item_path: &'a str,
1332 article_id: Option<&'a str>,
1333 }
1334
1335 self.post(
1336 "rss/markAsRead",
1337 Some(&Arg {
1338 item_path: item_path.as_ref(),
1339 article_id: article_id.as_ref().map(AsRef::as_ref),
1340 }),
1341 )
1342 .await?
1343 .end()
1344 }
1345
1346 pub async fn refresh_item<T: AsRef<str> + Send + Sync>(&self, item_path: T) -> Result<()> {
1347 #[derive(Serialize)]
1348 #[serde(rename_all = "camelCase")]
1349 struct Arg<'a> {
1350 item_path: &'a str,
1351 }
1352
1353 self.post(
1354 "rss/refreshItem",
1355 Some(&Arg {
1356 item_path: item_path.as_ref(),
1357 }),
1358 )
1359 .await?
1360 .end()
1361 }
1362
1363 pub async fn rename_rule<T: AsRef<str> + Send + Sync>(
1364 &self,
1365 rule_name: T,
1366 new_rule_name: T,
1367 ) -> Result<()> {
1368 #[derive(Serialize)]
1369 #[serde(rename_all = "camelCase")]
1370 struct Arg<'a> {
1371 rule_name: &'a str,
1372 new_rule_name: &'a str,
1373 }
1374
1375 self.post(
1376 "rss/renameRule",
1377 Some(&Arg {
1378 rule_name: rule_name.as_ref(),
1379 new_rule_name: new_rule_name.as_ref(),
1380 }),
1381 )
1382 .await?
1383 .end()
1384 }
1385
1386 pub async fn remove_rule<T: AsRef<str> + Send + Sync>(&self, rule_name: T) -> Result<()> {
1387 #[derive(Serialize)]
1388 #[serde(rename_all = "camelCase")]
1389 struct Arg<'a> {
1390 rule_name: &'a str,
1391 }
1392
1393 self.post(
1394 "rss/removeRule",
1395 Some(&Arg {
1396 rule_name: rule_name.as_ref(),
1397 }),
1398 )
1399 .await?
1400 .end()
1401 }
1402
1403 fn url(&self, path: &'static str) -> Url {
1404 self.endpoint
1405 .join("api/v2/")
1406 .unwrap()
1407 .join(path)
1408 .expect("Invalid API endpoint")
1409 }
1410
1411 fn state(&self) -> MutexGuard<'_, LoginState> {
1412 self.state.lock().unwrap()
1413 }
1414
1415 pub async fn login(&self, force: bool) -> Result<()> {
1418 let re_login = force || { self.state().as_cookie().is_none() };
1419 if re_login {
1420 debug!("Cookie not found, logging in");
1421 self.client
1422 .request(Method::POST, self.url("auth/login"))
1423 .pipe(|req| {
1424 req.form(
1425 self.state()
1426 .as_credential()
1427 .expect("Credential should be set if cookie is not set"),
1428 )
1429 })
1430 .send()
1431 .await?
1432 .map_status(|code| match code as _ {
1433 StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::IpBanned)),
1434 _ => None,
1435 })?
1436 .extract::<Cookie>()?
1437 .pipe(|Cookie(cookie)| self.state.lock().unwrap().add_cookie(cookie));
1438
1439 debug!("Log in success");
1440 } else {
1441 trace!("Already logged in, skipping");
1442 }
1443
1444 Ok(())
1445 }
1446
1447 async fn request(
1448 &self,
1449 method: Method,
1450 path: &'static str,
1451 body: Option<&(impl Serialize + Sync)>,
1452 ) -> Result<Response> {
1453 for i in 0..3 {
1454 self.login(i != 0).await?;
1456
1457 let mut req =
1458 self.client
1459 .request(method.clone(), self.url(path))
1460 .header(header::COOKIE, {
1461 self.state()
1462 .as_cookie()
1463 .expect("Cookie should be set after login")
1464 });
1465
1466 if let Some(ref body) = body {
1467 match method {
1468 Method::GET => req = req.query(body),
1469 Method::POST => req = req.form(body),
1470 _ => unreachable!("Only GET and POST are supported"),
1471 }
1472 }
1473 trace!(request = ?req, "Sending request");
1474 let res = req
1475 .send()
1476 .await?
1477 .map_status(|code| match code as _ {
1478 StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
1479 _ => None,
1480 })
1481 .tap_ok(|response| trace!(?response));
1482
1483 match res {
1484 Err(Error::ApiError(ApiError::NotLoggedIn)) => {
1485 warn!("Cookie is not valid, retrying");
1487 }
1488 Err(e) => return Err(e),
1489 Ok(t) => return Ok(t),
1490 }
1491 }
1492
1493 Err(Error::ApiError(ApiError::NotLoggedIn))
1494 }
1495
1496 async fn get(&self, path: &'static str) -> Result<Response> {
1497 self.request(Method::GET, path, NONE).await
1498 }
1499
1500 async fn get_with(
1501 &self,
1502 path: &'static str,
1503 param: &(impl Serialize + Sync),
1504 ) -> Result<Response> {
1505 self.request(Method::GET, path, Some(param)).await
1506 }
1507
1508 async fn post(
1509 &self,
1510 path: &'static str,
1511 body: Option<&(impl Serialize + Sync)>,
1512 ) -> Result<Response> {
1513 self.request(Method::POST, path, body).await
1514 }
1515}
1516
1517impl Clone for Qbit {
1518 fn clone(&self) -> Self {
1519 let state = self.state.lock().unwrap().clone();
1520 Self {
1521 client: self.client.clone(),
1522 endpoint: self.endpoint.clone(),
1523 state: Mutex::new(state),
1524 }
1525 }
1526}
1527
1528const NONE: Option<&'static ()> = Option::None;
1529
1530#[derive(Debug, thiserror::Error)]
1531pub enum Error {
1532 #[error("Http error: {0}")]
1533 HttpError(#[from] reqwest::Error),
1534
1535 #[error("API Returned bad response: {explain}")]
1536 BadResponse { explain: &'static str },
1537
1538 #[error("API returned unknown status code: {0}")]
1539 UnknownHttpCode(StatusCode),
1540
1541 #[error("Non ASCII header")]
1542 NonAsciiHeader,
1543
1544 #[error(transparent)]
1545 ApiError(#[from] ApiError),
1546
1547 #[error("serde_json error: {0}")]
1548 SerdeJsonError(#[from] serde_json::Error),
1549}
1550
1551#[derive(Debug, thiserror::Error)]
1553pub enum ApiError {
1554 #[error("User's IP is banned for too many failed login attempts")]
1555 IpBanned,
1556
1557 #[error("API routes requires login, try again")]
1558 NotLoggedIn,
1559
1560 #[error("Torrent not found")]
1561 TorrentNotFound,
1562
1563 #[error("Torrent name is empty")]
1564 TorrentNameEmpty,
1565
1566 #[error("`newUrl` is not a valid URL")]
1567 InvalidTrackerUrl,
1568
1569 #[error("`newUrl` already exists for the torrent or `origUrl` was not found")]
1570 ConflictTrackerUrl,
1571
1572 #[error("None of the given peers are valid")]
1573 InvalidPeers,
1574
1575 #[error("Torrent queueing is not enabled")]
1576 QueueingDisabled,
1577
1578 #[error("Torrent metadata hasn't downloaded yet or at least one file id was not found")]
1579 MetaNotDownloadedOrIdNotFound,
1580
1581 #[error("Save path is empty")]
1582 SavePathEmpty,
1583
1584 #[error("User does not have write access to the directory")]
1585 NoWriteAccess,
1586
1587 #[error("Unable to create save path directory")]
1588 UnableToCreateDir,
1589
1590 #[error("Category name does not exist")]
1591 CategoryNotFound,
1592
1593 #[error("Category editing failed")]
1594 CategoryEditingFailed,
1595
1596 #[error("Invalid `newPath` or `oldPath`, or `newPath` already in use")]
1597 InvalidPath,
1598}
1599
1600type Result<T, E = Error> = std::result::Result<T, E>;
1601
1602#[cfg(test)]
1603mod test {
1604 use std::{
1605 env,
1606 ops::Deref,
1607 sync::{LazyLock, OnceLock},
1608 };
1609
1610 use tracing::info;
1611
1612 use super::*;
1613
1614 async fn prepare<'a>() -> Result<&'a Qbit> {
1615 static PREPARE: LazyLock<(Credential, Url)> = LazyLock::new(|| {
1616 dotenv::dotenv().expect("Failed to load .env file");
1617 tracing_subscriber::fmt::init();
1618
1619 (
1620 Credential::new(
1621 env::var("QBIT_USERNAME").expect("QBIT_USERNAME not set"),
1622 env::var("QBIT_PASSWORD").expect("QBIT_PASSWORD not set"),
1623 ),
1624 env::var("QBIT_BASEURL")
1625 .expect("QBIT_BASEURL not set")
1626 .parse()
1627 .expect("QBIT_BASEURL is not a valid url"),
1628 )
1629 });
1630 static API: OnceLock<Qbit> = OnceLock::new();
1631
1632 if let Some(api) = API.get() {
1633 Ok(api)
1634 } else {
1635 let (credential, url) = PREPARE.deref().clone();
1636 let api = Qbit::new(url, credential);
1637 api.login(false).await?;
1638 drop(API.set(api));
1639 Ok(API.get().unwrap())
1640 }
1641 }
1642
1643 #[tokio::test]
1644 async fn test_login() {
1645 let client = prepare().await.unwrap();
1646
1647 info!(
1648 version = client.get_version().await.unwrap(),
1649 "Login success"
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn test_preference() {
1655 let client = prepare().await.unwrap();
1656
1657 client.get_preferences().await.unwrap();
1658 }
1659
1660 #[tokio::test]
1661 async fn test_add_torrent() {
1662 let client = prepare().await.unwrap();
1663 let arg = AddTorrentArg {
1664 source: TorrentSource::Urls {
1665 urls: vec![
1666 "https://releases.ubuntu.com/22.04/ubuntu-22.04.4-desktop-amd64.iso.torrent"
1667 .parse()
1668 .unwrap(),
1669 ]
1670 .into(),
1671 },
1672 ratio_limit: Some(1.0),
1673 ..AddTorrentArg::default()
1674 };
1675 client.add_torrent(arg).await.unwrap();
1676 }
1677 #[tokio::test]
1678 async fn test_add_torrent_file() {
1679 let client = prepare().await.unwrap();
1680 let arg = AddTorrentArg {
1681 source: TorrentSource::TorrentFiles {
1682 torrents: vec![ TorrentFile {
1683 filename: "ubuntu-22.04.4-desktop-amd64.iso.torrent".into(),
1684 data: reqwest::get("https://releases.ubuntu.com/22.04/ubuntu-22.04.4-desktop-amd64.iso.torrent")
1685 .await
1686 .unwrap()
1687 .bytes()
1688 .await
1689 .unwrap()
1690 .to_vec(),
1691 }]
1692 },
1693 ratio_limit: Some(1.0),
1694 ..AddTorrentArg::default()
1695 };
1696 client.add_torrent(arg).await.unwrap();
1697 }
1698
1699 #[tokio::test]
1700 async fn test_get_torrent_list() {
1701 let client = prepare().await.unwrap();
1702 let list = client
1703 .get_torrent_list(GetTorrentListArg::default())
1704 .await
1705 .unwrap();
1706 print!("{:#?}", list);
1707 }
1708}