qbit_rs/
lib.rs

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
72/// Main entry point of the library. It provides a high-level API to interact
73/// with qBittorrent WebUI API.
74pub struct Qbit {
75    client: Client,
76    endpoint: Url,
77    state: Mutex<LoginState>,
78}
79
80impl Qbit {
81    /// Create a new [`QbitBuilder`] to build a [`Qbit`] instance.
82    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                    // If it's not the first attempt, we need to re-login
512                    self.login(i != 0).await?;
513                    // Create a multipart form containing the torrent files and other arguments
514                    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                                // If we directly call to_string() on a Value containing a string like "hello",
521                                // it will include the quotes: "\"hello\"".
522                                // We need to use as_str() first to get the inner string without quotes.
523                                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                            // Retry
561                            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    /// Log in to qBittorrent. Set force to `true` to forcefully re-login
1416    /// regardless if cookie is already set.
1417    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            // If it's not the first attempt, we need to re-login
1455            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                    // Retry
1486                    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/// Errors defined and returned by the API
1552#[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}