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