Skip to main content

qbit_rs/endpoint/
torrent.rs

1use std::{borrow::Borrow, collections::HashMap, path::Path};
2
3use bytes::Bytes;
4use serde::Serialize;
5use serde_with::skip_serializing_none;
6use tap::Pipe;
7
8#[cfg(feature = "cyper")]
9use crate::client::PartExt;
10use crate::{
11    ApiError, Error, Qbit, Result,
12    client::{CheckError, Method, RequestBuilder, StatusCode, Url, multipart},
13    ext::*,
14    model::*,
15};
16
17impl Qbit {
18    /// Return torrents matching the supplied filters and pagination options.
19    pub async fn get_torrent_list(&self, arg: GetTorrentListArg) -> Result<Vec<Torrent>> {
20        self.get_with("torrents/info", &arg)
21            .await?
22            .json()
23            .await
24            .map_err(Into::into)
25    }
26
27    /// Export the torrent metadata file for the supplied hash.
28    pub async fn export_torrent(&self, hash: impl AsRef<str> + Send + Sync) -> Result<Bytes> {
29        self.get_with("torrents/export", &HashArg::new(hash.as_ref()))
30            .await?
31            .bytes()
32            .await
33            .map_err(Into::into)
34    }
35
36    /// Download a completed file from a torrent's content.
37    ///
38    /// `file` can be either a file index (as a number) or a path relative
39    /// to the torrent content root.
40    ///
41    /// Added in qBittorrent 5.2.0 (Web API v2.16.0).
42    pub async fn download_torrent_file(
43        &self,
44        hash: impl AsRef<str> + Send + Sync,
45        file: impl AsRef<str> + Send + Sync,
46    ) -> Result<Bytes> {
47        #[derive(Serialize)]
48        struct Arg<'a> {
49            hash: &'a str,
50            file: &'a str,
51        }
52        self.post_with(
53            "torrents/downloadFile",
54            &Arg {
55                hash: hash.as_ref(),
56                file: file.as_ref(),
57            },
58        )
59        .await?
60        .map_status(|c| match c {
61            StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
62            StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
63            _ => None,
64        })?
65        .bytes()
66        .await
67        .map_err(Into::into)
68    }
69
70    /// Return generic properties for the supplied torrent.
71    pub async fn get_torrent_properties(
72        &self,
73        hash: impl AsRef<str> + Send + Sync,
74    ) -> Result<TorrentProperty> {
75        self.get_with("torrents/properties", &HashArg::new(hash.as_ref()))
76            .await
77            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
78            .json()
79            .await
80            .map_err(Into::into)
81    }
82
83    /// Get the availability (number of distributed copies) of each piece
84    /// of a torrent. Returns a vector where each element is the availability
85    /// count for the corresponding piece index.
86    ///
87    /// Added in qBittorrent 5.2.0 (Web API v2.15.1).
88    pub async fn get_torrent_piece_availability(
89        &self,
90        hash: impl AsRef<str> + Send + Sync,
91    ) -> Result<Vec<i64>> {
92        self.get_with("torrents/pieceAvailability", &HashArg::new(hash.as_ref()))
93            .await
94            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
95            .json()
96            .await
97            .map_err(Into::into)
98    }
99
100    /// Return trackers for the supplied torrent.
101    pub async fn get_torrent_trackers(
102        &self,
103        hash: impl AsRef<str> + Send + Sync,
104    ) -> Result<Vec<Tracker>> {
105        self.get_with("torrents/trackers", &HashArg::new(hash.as_ref()))
106            .await
107            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
108            .json()
109            .await
110            .map_err(Into::into)
111    }
112
113    /// Return web seeds for the supplied torrent.
114    pub async fn get_torrent_web_seeds(
115        &self,
116        hash: impl AsRef<str> + Send + Sync,
117    ) -> Result<Vec<WebSeed>> {
118        self.get_with("torrents/webseeds", &HashArg::new(hash.as_ref()))
119            .await
120            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
121            .json()
122            .await
123            .map_err(Into::into)
124    }
125
126    /// Return file content information for the supplied torrent.
127    pub async fn get_torrent_contents(
128        &self,
129        hash: impl AsRef<str> + Send + Sync,
130        indexes: impl Into<Option<Sep<String, '|'>>> + Send + Sync,
131    ) -> Result<Vec<TorrentContent>> {
132        #[derive(Serialize)]
133        struct Arg<'a> {
134            hash: &'a str,
135            #[serde(skip_serializing_if = "Option::is_none")]
136            indexes: Option<String>,
137        }
138
139        self.get_with(
140            "torrents/files",
141            &Arg {
142                hash: hash.as_ref(),
143                indexes: indexes.into().map(|s| s.to_string()),
144            },
145        )
146        .await
147        .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
148        .json()
149        .await
150        .map_err(Into::into)
151    }
152
153    /// Return the download state of each piece in the supplied torrent.
154    pub async fn get_torrent_pieces_states(
155        &self,
156        hash: impl AsRef<str> + Send + Sync,
157    ) -> Result<Vec<PieceState>> {
158        self.get_with("torrents/pieceStates", &HashArg::new(hash.as_ref()))
159            .await
160            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
161            .json()
162            .await
163            .map_err(Into::into)
164    }
165
166    /// Return the hash of each piece in the supplied torrent.
167    pub async fn get_torrent_pieces_hashes(
168        &self,
169        hash: impl AsRef<str> + Send + Sync,
170    ) -> Result<Vec<String>> {
171        self.get_with("torrents/pieceHashes", &HashArg::new(hash.as_ref()))
172            .await
173            .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
174            .json()
175            .await
176            .map_err(Into::into)
177    }
178
179    /// Stop the supplied torrents.
180    pub async fn stop_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
181        self.post_with("torrents/stop", &HashesArg::new(hashes))
182            .await?
183            .end()
184    }
185
186    /// Start the supplied torrents.
187    pub async fn start_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
188        self.post_with("torrents/start", &HashesArg::new(hashes))
189            .await?
190            .end()
191    }
192
193    /// Delete the supplied torrents, optionally including their downloaded
194    /// files.
195    pub async fn delete_torrents(
196        &self,
197        hashes: impl Into<Hashes> + Send + Sync,
198        delete_files: impl Into<Option<bool>> + Send + Sync,
199    ) -> Result<()> {
200        #[derive(Serialize)]
201        #[skip_serializing_none]
202        #[serde(rename_all = "camelCase")]
203        struct Arg {
204            hashes: Hashes,
205            delete_files: Option<bool>,
206        }
207        self.post_with(
208            "torrents/delete",
209            &Arg {
210                hashes: hashes.into(),
211                delete_files: delete_files.into(),
212            },
213        )
214        .await?
215        .end()
216    }
217
218    /// Recheck the supplied torrents against their downloaded data.
219    pub async fn recheck_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
220        self.post_with("torrents/recheck", &HashesArg::new(hashes))
221            .await?
222            .end()
223    }
224
225    /// Reannounce the supplied torrents to their trackers.
226    pub async fn reannounce_torrents(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
227        self.post_with("torrents/reannounce", &HashesArg::new(hashes))
228            .await?
229            .end()
230    }
231
232    /// Reannounce torrents, optionally specifying which trackers to contact.
233    ///
234    /// `trackers` is a pipe-separated list of tracker URLs. Added in
235    /// qBittorrent 5.2.0 (Web API v2.11.10).
236    pub async fn reannounce_torrents_with_trackers(
237        &self,
238        hashes: impl Into<Hashes> + Send + Sync,
239        trackers: impl Into<Sep<String, '|'>> + Send + Sync,
240    ) -> Result<()> {
241        #[derive(Serialize)]
242        struct Arg {
243            hashes: String,
244            trackers: String,
245        }
246        self.post_with(
247            "torrents/reannounce",
248            &Arg {
249                hashes: hashes.into().to_string(),
250                trackers: trackers.into().to_string(),
251            },
252        )
253        .await?
254        .end()
255    }
256
257    /// Add one or more torrents from URLs or torrent files.
258    pub async fn add_torrent(&self, arg: impl Borrow<AddTorrentArg> + Send + Sync) -> Result<()> {
259        use multipart::Form;
260
261        fn make_form(
262            arg: &AddTorrentArg,
263            torrents: &[TorrentFile],
264        ) -> Result<Form, serde_json::Error> {
265            let form = serde_json::to_value(arg)?
266                .as_object()
267                .unwrap()
268                .into_iter()
269                .fold(Form::new(), |form, (k, v)| {
270                    let v = match v.as_str() {
271                        Some(v_str) => v_str.to_string(),
272                        None => v.to_string(),
273                    };
274                    form.text(k.to_string(), v.to_string())
275                });
276
277            torrents
278                .iter()
279                .fold(form, |mut form, torrent| {
280                    let p = multipart::Part::bytes(torrent.data.clone())
281                        .file_name(torrent.filename.to_string())
282                        .mime_str("application/x-bittorrent")
283                        .unwrap();
284                    form = form.part("torrents", p);
285                    form
286                })
287                .pipe(Ok)
288        }
289
290        let args: &AddTorrentArg = arg.borrow();
291
292        // qBittorrent 5.0+ renamed the `paused` field to `stopped`. Mirror
293        // whichever one the caller set into the other so the request is
294        // honored regardless of server version.
295        // See <https://github.com/George-Miao/qbit/issues/40>.
296        let owned;
297        let args = if args.paused.is_some() ^ args.stopped.is_some() {
298            let mut args = args.clone();
299            if args.paused.is_none() {
300                args.paused = args.stopped.clone();
301            } else {
302                args.stopped = args.paused.clone();
303            }
304            owned = args;
305            &owned
306        } else {
307            args
308        };
309
310        match &args.source {
311            TorrentSource::Urls { urls: _ } => self.post_with("torrents/add", args).await?.end(),
312            TorrentSource::TorrentFiles { torrents } => self
313                .request(
314                    Method::POST,
315                    "torrents/add",
316                    Some(|req: RequestBuilder| req.multipart(make_form(args, torrents)?).check()),
317                )
318                .await?
319                .map_status(|code| match code as _ {
320                    StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
321                    StatusCode::UNSUPPORTED_MEDIA_TYPE => {
322                        Some(Error::ApiError(ApiError::TorrentFileInvalid))
323                    }
324                    // qBittorrent 5.2.0+: 409 = all torrents failed (e.g. duplicate)
325                    StatusCode::CONFLICT => Some(Error::ApiError(ApiError::TorrentAddFailed)),
326                    _ => None,
327                })?
328                .end(),
329        }
330    }
331
332    /// Add trackers to the supplied torrent.
333    pub async fn add_trackers(
334        &self,
335        hash: impl AsRef<str> + Send + Sync,
336        urls: impl Into<Sep<String, '\n'>> + Send + Sync,
337    ) -> Result<()> {
338        #[derive(Serialize)]
339        struct Arg<'a> {
340            hash: &'a str,
341            urls: String,
342        }
343
344        self.post_with(
345            "torrents/addTrackers",
346            &Arg {
347                hash: hash.as_ref(),
348                urls: urls.into().to_string(),
349            },
350        )
351        .await
352        .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
353        .json()
354        .await
355        .map_err(Into::into)
356    }
357
358    /// Replace a tracker URL on the supplied torrent.
359    pub async fn edit_trackers(
360        &self,
361        hash: impl AsRef<str> + Send + Sync,
362        orig_url: Url,
363        new_url: Url,
364    ) -> Result<()> {
365        #[derive(Serialize)]
366        #[serde(rename_all = "camelCase")]
367        struct EditTrackerArg<'a> {
368            hash: &'a str,
369            orig_url: Url,
370            new_url: Url,
371        }
372        self.post_with(
373            "torrents/editTracker",
374            &EditTrackerArg {
375                hash: hash.as_ref(),
376                orig_url,
377                new_url,
378            },
379        )
380        .await?
381        .map_status(|c| match c {
382            StatusCode::BAD_REQUEST => Some(Error::ApiError(ApiError::InvalidTrackerUrl)),
383            StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
384            StatusCode::CONFLICT => Some(Error::ApiError(ApiError::ConflictTrackerUrl)),
385            _ => None,
386        })?
387        .end()
388    }
389
390    /// Remove trackers from the supplied torrent.
391    pub async fn remove_trackers(
392        &self,
393        hash: impl AsRef<str> + Send + Sync,
394        urls: impl Into<Sep<Url, '|'>> + Send + Sync,
395    ) -> Result<()> {
396        #[derive(Serialize)]
397        struct Arg<'a> {
398            hash: &'a str,
399            urls: Sep<Url, '|'>,
400        }
401
402        self.post_with(
403            "torrents/removeTrackers",
404            &Arg {
405                hash: hash.as_ref(),
406                urls: urls.into(),
407            },
408        )
409        .await
410        .and_then(|r| r.map_status(TORRENT_NOT_FOUND))?
411        .end()
412    }
413
414    /// Add peers to the supplied torrents.
415    pub async fn add_peers(
416        &self,
417        hashes: impl Into<Hashes> + Send + Sync,
418        peers: impl Into<Sep<String, '|'>> + Send + Sync,
419    ) -> Result<()> {
420        #[derive(Serialize)]
421        struct AddPeersArg {
422            hash: String,
423            peers: Sep<String, '|'>,
424        }
425
426        self.post_with(
427            "torrents/addPeers",
428            &AddPeersArg {
429                hash: hashes.into().to_string(),
430                peers: peers.into(),
431            },
432        )
433        .await
434        .and_then(|r| {
435            r.map_status(|c| {
436                if c == StatusCode::BAD_REQUEST {
437                    Some(Error::ApiError(ApiError::InvalidPeers))
438                } else {
439                    None
440                }
441            })
442        })?
443        .end()
444    }
445
446    /// Increase the queue priority of the supplied torrents.
447    pub async fn increase_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
448        self.post_with("torrents/increasePrio", &HashesArg::new(hashes))
449            .await?
450            .map_status(|c| {
451                if c == StatusCode::CONFLICT {
452                    Some(Error::ApiError(ApiError::QueueingDisabled))
453                } else {
454                    None
455                }
456            })?;
457        Ok(())
458    }
459
460    /// Decrease the queue priority of the supplied torrents.
461    pub async fn decrease_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
462        self.post_with("torrents/decreasePrio", &HashesArg::new(hashes))
463            .await?
464            .map_status(|c| {
465                if c == StatusCode::CONFLICT {
466                    Some(Error::ApiError(ApiError::QueueingDisabled))
467                } else {
468                    None
469                }
470            })?;
471        Ok(())
472    }
473
474    /// Move the supplied torrents to the top of the queue.
475    pub async fn maximal_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
476        self.post_with("torrents/topPrio", &HashesArg::new(hashes))
477            .await?
478            .map_status(|c| {
479                if c == StatusCode::CONFLICT {
480                    Some(Error::ApiError(ApiError::QueueingDisabled))
481                } else {
482                    None
483                }
484            })?;
485        Ok(())
486    }
487
488    /// Move the supplied torrents to the bottom of the queue.
489    pub async fn minimal_priority(&self, hashes: impl Into<Hashes> + Send + Sync) -> Result<()> {
490        self.post_with("torrents/bottomPrio", &HashesArg::new(hashes))
491            .await?
492            .map_status(|c| {
493                if c == StatusCode::CONFLICT {
494                    Some(Error::ApiError(ApiError::QueueingDisabled))
495                } else {
496                    None
497                }
498            })?;
499        Ok(())
500    }
501
502    /// Set the download priority of selected files in a torrent.
503    pub async fn set_file_priority(
504        &self,
505        hash: impl AsRef<str> + Send + Sync,
506        indexes: impl Into<Sep<i64, '|'>> + Send + Sync,
507        priority: Priority,
508    ) -> Result<()> {
509        #[derive(Serialize)]
510        struct SetFilePriorityArg<'a> {
511            hash: &'a str,
512            id: Sep<i64, '|'>,
513            priority: Priority,
514        }
515
516        self.post_with(
517            "torrents/filePrio",
518            &SetFilePriorityArg {
519                hash: hash.as_ref(),
520                id: indexes.into(),
521                priority,
522            },
523        )
524        .await?
525        .map_status(|c| match c {
526            StatusCode::BAD_REQUEST => panic!("Invalid priority or id. This is a bug."),
527            StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
528            StatusCode::CONFLICT => Some(Error::ApiError(ApiError::MetaNotDownloadedOrIdNotFound)),
529            _ => None,
530        })?;
531        Ok(())
532    }
533
534    /// Return the download limit for the supplied torrent.
535    pub async fn get_torrent_download_limit(
536        &self,
537        hashes: impl Into<Hashes> + Send + Sync,
538    ) -> Result<HashMap<String, u64>> {
539        self.get_with("torrents/downloadLimit", &HashesArg::new(hashes))
540            .await?
541            .json()
542            .await
543            .map_err(Into::into)
544    }
545
546    /// Set the download limit for the supplied torrents.
547    pub async fn set_torrent_download_limit(
548        &self,
549        hashes: impl Into<Hashes> + Send + Sync,
550        limit: u64,
551    ) -> Result<()> {
552        #[derive(Serialize)]
553        struct Arg {
554            hashes: String,
555            limit: u64,
556        }
557
558        self.post_with(
559            "torrents/downloadLimit",
560            &Arg {
561                hashes: hashes.into().to_string(),
562                limit,
563            },
564        )
565        .await?
566        .end()
567    }
568
569    /// Set share-ratio and seeding-time limits for the supplied torrents.
570    pub async fn set_torrent_shared_limit(
571        &self,
572        arg: impl Borrow<SetTorrentSharedLimitArg> + Send + Sync,
573    ) -> Result<()> {
574        self.post_with("torrents/setShareLimits", arg.borrow())
575            .await?
576            .end()
577    }
578
579    /// Return the upload limit for the supplied torrent.
580    pub async fn get_torrent_upload_limit(
581        &self,
582        hashes: impl Into<Hashes> + Send + Sync,
583    ) -> Result<HashMap<String, u64>> {
584        self.get_with("torrents/uploadLimit", &HashesArg::new(hashes))
585            .await?
586            .json()
587            .await
588            .map_err(Into::into)
589    }
590
591    /// Set the upload limit for the supplied torrents.
592    pub async fn set_torrent_upload_limit(
593        &self,
594        hashes: impl Into<Hashes> + Send + Sync,
595        limit: u64,
596    ) -> Result<()> {
597        #[derive(Serialize)]
598        struct Arg {
599            hashes: String,
600            limit: u64,
601        }
602
603        self.post_with(
604            "torrents/uploadLimit",
605            &Arg {
606                hashes: hashes.into().to_string(),
607                limit,
608            },
609        )
610        .await?
611        .end()
612    }
613
614    /// Set the save location for the supplied torrents.
615    pub async fn set_torrent_location(
616        &self,
617        hashes: impl Into<Hashes> + Send + Sync,
618        location: impl AsRef<Path> + Send + Sync,
619    ) -> Result<()> {
620        #[derive(Serialize)]
621        struct Arg<'a> {
622            hashes: String,
623            location: &'a Path,
624        }
625
626        self.post_with(
627            "torrents/setLocation",
628            &Arg {
629                hashes: hashes.into().to_string(),
630                location: location.as_ref(),
631            },
632        )
633        .await?
634        .map_status(|c| match c {
635            StatusCode::BAD_REQUEST => Some(Error::ApiError(ApiError::SavePathEmpty)),
636            StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NoWriteAccess)),
637            StatusCode::CONFLICT => Some(Error::ApiError(ApiError::UnableToCreateDir)),
638            _ => None,
639        })?
640        .end()
641    }
642
643    /// Rename the supplied torrent.
644    pub async fn set_torrent_name<T: AsRef<str> + Send + Sync>(
645        &self,
646        hash: impl AsRef<str> + Send + Sync,
647        name: NonEmptyStr<T>,
648    ) -> Result<()> {
649        #[derive(Serialize)]
650        struct RenameArg<'a> {
651            hash: &'a str,
652            name: &'a str,
653        }
654
655        self.post_with(
656            "torrents/rename",
657            &RenameArg {
658                hash: hash.as_ref(),
659                name: name.as_str(),
660            },
661        )
662        .await?
663        .map_status(|c| match c {
664            StatusCode::NOT_FOUND => Some(Error::ApiError(ApiError::TorrentNotFound)),
665            StatusCode::CONFLICT => panic!("Name should not be empty. This is a bug."),
666            _ => None,
667        })?
668        .end()
669    }
670
671    /// Set the comment for one or more torrents.
672    ///
673    /// Added in qBittorrent 5.2.0 (Web API v2.12.1).
674    pub async fn set_torrent_comment(
675        &self,
676        hashes: impl Into<Hashes> + Send + Sync,
677        comment: &str,
678    ) -> Result<()> {
679        #[derive(Serialize)]
680        struct Arg<'a> {
681            hashes: String,
682            comment: &'a str,
683        }
684
685        self.post_with(
686            "torrents/setComment",
687            &Arg {
688                hashes: hashes.into().to_string(),
689                comment,
690            },
691        )
692        .await?
693        .map_status(|c| match c {
694            StatusCode::FORBIDDEN => Some(Error::ApiError(ApiError::NotLoggedIn)),
695            _ => None,
696        })?
697        .end()
698    }
699
700    /// Set the category of the supplied torrents.
701    pub async fn set_torrent_category(
702        &self,
703        hashes: impl Into<Hashes> + Send + Sync,
704        category: impl AsRef<str> + Send + Sync,
705    ) -> Result<()> {
706        #[derive(Serialize)]
707        struct Arg<'a> {
708            hashes: String,
709            category: &'a str,
710        }
711
712        self.post_with(
713            "torrents/setCategory",
714            &Arg {
715                hashes: hashes.into().to_string(),
716                category: category.as_ref(),
717            },
718        )
719        .await?
720        .map_status(|c| {
721            if c == StatusCode::CONFLICT {
722                Some(Error::ApiError(ApiError::CategoryNotFound))
723            } else {
724                None
725            }
726        })?
727        .end()
728    }
729
730    /// Return all torrent categories.
731    pub async fn get_categories(&self) -> Result<HashMap<String, Category>> {
732        self.get("torrents/categories")
733            .await?
734            .json()
735            .await
736            .map_err(Into::into)
737    }
738
739    /// Create a torrent category with the supplied save path.
740    pub async fn add_category<T: AsRef<str> + Send + Sync>(
741        &self,
742        category: NonEmptyStr<T>,
743        save_path: impl AsRef<Path> + Send + Sync,
744    ) -> Result<()> {
745        #[derive(Serialize)]
746        #[serde(rename_all = "camelCase")]
747        struct Arg<'a> {
748            category: &'a str,
749            save_path: &'a Path,
750        }
751
752        self.post_with(
753            "torrents/createCategory",
754            &Arg {
755                category: category.as_str(),
756                save_path: save_path.as_ref(),
757            },
758        )
759        .await?
760        .end()
761    }
762
763    /// Update the save path of a torrent category.
764    pub async fn edit_category<T: AsRef<str> + Send + Sync>(
765        &self,
766        category: NonEmptyStr<T>,
767        save_path: impl AsRef<Path> + Send + Sync,
768    ) -> Result<()> {
769        #[derive(Serialize)]
770        #[serde(rename_all = "camelCase")]
771        struct Arg<'a> {
772            category: &'a str,
773            save_path: &'a Path,
774        }
775
776        self.post_with(
777            "torrents/createCategory",
778            &Arg {
779                category: category.as_str(),
780                save_path: save_path.as_ref(),
781            },
782        )
783        .await?
784        .map_status(|c| {
785            if c == StatusCode::CONFLICT {
786                Some(Error::ApiError(ApiError::CategoryEditingFailed))
787            } else {
788                None
789            }
790        })?
791        .end()
792    }
793
794    /// Remove the supplied torrent categories.
795    pub async fn remove_categories(
796        &self,
797        categories: impl Into<Sep<String, '\n'>> + Send + Sync,
798    ) -> Result<()> {
799        #[derive(Serialize)]
800        struct Arg<'a> {
801            categories: &'a str,
802        }
803
804        self.post_with(
805            "torrents/removeCategories",
806            &Arg {
807                categories: &categories.into().to_string(),
808            },
809        )
810        .await?
811        .end()
812    }
813
814    /// Add tags to the supplied torrents.
815    pub async fn add_torrent_tags(
816        &self,
817        hashes: impl Into<Hashes> + Send + Sync,
818        tags: impl Into<Sep<String, '\n'>> + Send + Sync,
819    ) -> Result<()> {
820        #[derive(Serialize)]
821        struct Arg<'a> {
822            hashes: String,
823            tags: &'a str,
824        }
825
826        self.post_with(
827            "torrents/addTags",
828            &Arg {
829                hashes: hashes.into().to_string(),
830                tags: &tags.into().to_string(),
831            },
832        )
833        .await?
834        .end()
835    }
836
837    /// Remove tags from the supplied torrents.
838    pub async fn remove_torrent_tags(
839        &self,
840        hashes: impl Into<Hashes> + Send + Sync,
841        tags: Option<impl Into<Sep<String, ','>> + Send>,
842    ) -> Result<()> {
843        #[derive(Serialize)]
844        #[skip_serializing_none]
845        struct Arg {
846            hashes: String,
847            tags: Option<String>,
848        }
849
850        self.post_with(
851            "torrents/removeTags",
852            &Arg {
853                hashes: hashes.into().to_string(),
854                tags: tags.map(|t| t.into().to_string()),
855            },
856        )
857        .await?
858        .end()
859    }
860
861    /// Return all torrent tags.
862    pub async fn get_all_tags(&self) -> Result<Vec<String>> {
863        self.get("torrents/tags")
864            .await?
865            .json()
866            .await
867            .map_err(Into::into)
868    }
869
870    /// Create the supplied torrent tags.
871    pub async fn create_tags(&self, tags: impl Into<Sep<String, ','>> + Send + Sync) -> Result<()> {
872        #[derive(Serialize)]
873        struct Arg {
874            tags: String,
875        }
876
877        self.post_with(
878            "torrents/createTags",
879            &Arg {
880                tags: tags.into().to_string(),
881            },
882        )
883        .await?
884        .end()
885    }
886
887    /// Delete the supplied torrent tags.
888    pub async fn delete_tags(&self, tags: impl Into<Sep<String, ','>> + Send + Sync) -> Result<()> {
889        #[derive(Serialize)]
890        struct Arg {
891            tags: String,
892        }
893
894        self.post_with(
895            "torrents/deleteTags",
896            &Arg {
897                tags: tags.into().to_string(),
898            },
899        )
900        .await?
901        .end()
902    }
903
904    /// Enable or disable automatic torrent management for the supplied
905    /// torrents.
906    pub async fn set_auto_management(
907        &self,
908        hashes: impl Into<Hashes> + Send + Sync,
909        enable: bool,
910    ) -> Result<()> {
911        #[derive(Serialize)]
912        struct Arg {
913            hashes: String,
914            enable: bool,
915        }
916
917        self.post_with(
918            "torrents/setAutoManagement",
919            &Arg {
920                hashes: hashes.into().to_string(),
921                enable,
922            },
923        )
924        .await?
925        .end()
926    }
927
928    /// Toggle sequential downloading for the supplied torrents.
929    pub async fn toggle_sequential_download(
930        &self,
931        hashes: impl Into<Hashes> + Send + Sync,
932    ) -> Result<()> {
933        self.post_with("torrents/toggleSequentialDownload", &HashesArg::new(hashes))
934            .await?
935            .end()
936    }
937
938    /// Toggle first and last piece priority for the supplied torrents.
939    pub async fn toggle_first_last_piece_priority(
940        &self,
941        hashes: impl Into<Hashes> + Send + Sync,
942    ) -> Result<()> {
943        self.post_with("torrents/toggleFirstLastPiecePrio", &HashesArg::new(hashes))
944            .await?
945            .end()
946    }
947
948    /// Enable or disable force start for the supplied torrents.
949    pub async fn set_force_start(
950        &self,
951        hashes: impl Into<Hashes> + Send + Sync,
952        value: bool,
953    ) -> Result<()> {
954        #[derive(Serialize)]
955        struct Arg {
956            hashes: String,
957            value: bool,
958        }
959
960        self.post_with(
961            "torrents/setForceStart",
962            &Arg {
963                hashes: hashes.into().to_string(),
964                value,
965            },
966        )
967        .await?
968        .end()
969    }
970
971    /// Enable or disable super seeding for the supplied torrents.
972    pub async fn set_super_seeding(
973        &self,
974        hashes: impl Into<Hashes> + Send + Sync,
975        value: bool,
976    ) -> Result<()> {
977        #[derive(Serialize)]
978        struct Arg {
979            hashes: String,
980            value: bool,
981        }
982
983        self.post_with(
984            "torrents/setSuperSeeding",
985            &Arg {
986                hashes: hashes.into().to_string(),
987                value,
988            },
989        )
990        .await?
991        .end()
992    }
993
994    /// Rename a file within the supplied torrent.
995    pub async fn rename_file(
996        &self,
997        hash: impl AsRef<str> + Send + Sync,
998        old_path: impl AsRef<Path> + Send + Sync,
999        new_path: impl AsRef<Path> + Send + Sync,
1000    ) -> Result<()> {
1001        #[derive(Serialize)]
1002        #[serde(rename_all = "camelCase")]
1003        struct Arg<'a> {
1004            hash: &'a str,
1005            old_path: &'a Path,
1006            new_path: &'a Path,
1007        }
1008
1009        self.post_with(
1010            "torrents/renameFile",
1011            &Arg {
1012                hash: hash.as_ref(),
1013                old_path: old_path.as_ref(),
1014                new_path: new_path.as_ref(),
1015            },
1016        )
1017        .await?
1018        .map_status(|c| {
1019            if c == StatusCode::CONFLICT {
1020                Error::ApiError(ApiError::InvalidPath).pipe(Some)
1021            } else {
1022                None
1023            }
1024        })?
1025        .end()
1026    }
1027
1028    /// Rename a folder within the supplied torrent.
1029    pub async fn rename_folder(
1030        &self,
1031        hash: impl AsRef<str> + Send + Sync,
1032        old_path: impl AsRef<Path> + Send + Sync,
1033        new_path: impl AsRef<Path> + Send + Sync,
1034    ) -> Result<()> {
1035        #[derive(Serialize)]
1036        #[serde(rename_all = "camelCase")]
1037        struct Arg<'a> {
1038            hash: &'a str,
1039            old_path: &'a Path,
1040            new_path: &'a Path,
1041        }
1042
1043        self.post_with(
1044            "torrents/renameFolder",
1045            &Arg {
1046                hash: hash.as_ref(),
1047                old_path: old_path.as_ref(),
1048                new_path: new_path.as_ref(),
1049            },
1050        )
1051        .await?
1052        .map_status(|c| {
1053            if c == StatusCode::CONFLICT {
1054                Error::ApiError(ApiError::InvalidPath).pipe(Some)
1055            } else {
1056                None
1057            }
1058        })?
1059        .end()
1060    }
1061}