Skip to main content

qbit_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::{
6    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
74/// Main entry point of the library. It provides a high-level API to interact
75/// with qBittorrent WebUI API.
76pub struct Qbit {
77    client: Client,
78    endpoint: Url,
79    state: Mutex<LoginState>,
80}
81
82impl Qbit {
83    /// Create a new [`QbitBuilder`] to build a [`Qbit`] instance.
84    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    /// Log in to qBittorrent.
1386    ///
1387    /// Set force to `true` to force re-login regardless if cookie is already
1388    /// set.
1389    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            // If it's not the first attempt, we need to re-login
1463            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                    // Retry
1494                    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/// Errors defined and returned by the API
1540#[derive(Debug, thiserror::Error)]
1541pub enum ApiError {
1542    /// User's IP is banned for too many failed login attempts
1543    #[error("User's IP is banned for too many failed login attempts")]
1544    IpBanned,
1545
1546    /// API routes requires login, try again
1547    #[error("API routes requires login, try again")]
1548    NotLoggedIn,
1549
1550    /// Torrent not found
1551    #[error("Torrent not found")]
1552    TorrentNotFound,
1553
1554    /// Torrent name is empty
1555    #[error("Torrent name is empty")]
1556    TorrentNameEmpty,
1557
1558    /// Torrent file is not valid
1559    #[error("Torrent file is not valid")]
1560    TorrentFileInvalid,
1561
1562    /// `newUrl` is not a valid URL
1563    #[error("`newUrl` is not a valid URL")]
1564    InvalidTrackerUrl,
1565
1566    /// `newUrl` already exists for the torrent or `origUrl` was not found
1567    #[error("`newUrl` already exists for the torrent or `origUrl` was not found")]
1568    ConflictTrackerUrl,
1569
1570    /// None of the given peers are valid
1571    #[error("None of the given peers are valid")]
1572    InvalidPeers,
1573
1574    /// Torrent queueing is not enabled
1575    #[error("Torrent queueing is not enabled")]
1576    QueueingDisabled,
1577
1578    /// Torrent metadata hasn't downloaded yet or at least one file id was not
1579    /// found
1580    #[error("Torrent metadata hasn't downloaded yet or at least one file id was not found")]
1581    MetaNotDownloadedOrIdNotFound,
1582
1583    /// Save path is empty
1584    #[error("Save path is empty")]
1585    SavePathEmpty,
1586
1587    /// User does not have write access to the directory
1588    #[error("User does not have write access to the directory")]
1589    NoWriteAccess,
1590
1591    /// Unable to create save path directory
1592    #[error("Unable to create save path directory")]
1593    UnableToCreateDir,
1594
1595    /// Category name does not exist
1596    #[error("Category name does not exist")]
1597    CategoryNotFound,
1598
1599    /// Category editing failed
1600    #[error("Category editing failed")]
1601    CategoryEditingFailed,
1602
1603    /// Invalid `newPath` or `oldPath`, or `newPath` already in use
1604    #[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}