diff --git a/CHANGELOG.md b/CHANGELOG.md index 86af20f..e509a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## UNRELEASED (YYYY-MM-DD) +### Breaking changes + +- `AddSource` no longer has public constructor associated methods, construct the + enum variant directly +- `AddSource` `MagnetStr`, `MagnetFile` and `TorrentFile` variants have been deprecated + in favor of `Magnet` and `Torrent` variants which expect a typed `hightorrent::MagnetLink` + and `hightorrent::TorrentFile` respectively, in order to reduce the chances to fail + parsing later in the backend ; this also enables to add an already-parsed torrent + from memory and not from disk +- `AddBuilder::magnet` and `AddBuilder::torrent` also expect parsed arguments + +### Changed + +- `QBittorrentClient::add` has been replaced with `Api::add` which has a default implementation + that will be used by other backends in the future; this does not break the existing API + ## Version 0.2.2 (2026-05-28) ### Added diff --git a/Cargo.toml b/Cargo.toml index 3b26c4a..b816167 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ hightorrent = { version = "0.4.1" } # Uncomment below for local development # hightorrent = { path = "../hightorrent" } # hightorrent = { git = "https://github.com/angrynode/hightorrent", branch = "feat-sea-orm" } -tokio = { version = "1", features = [ "fs" ] } +tokio = { version = "1" } tokio-util = { version = "0.7" } async-trait = "0.1" @@ -30,7 +30,7 @@ snafu = "0.8" serde = { version = "1", features = [ "derive" ] } serde_json = "1" -reqwest = { version = "0.12", optional = true, default-features = false, features = [ "multipart", "json", "cookies", "stream" ] } +reqwest = { version = "0.12", optional = true, default-features = false, features = [ "multipart", "json", "cookies" ] } [dev-dependencies] # Required for tokio::test macro diff --git a/README.md b/README.md index 8a5586a..e543a0b 100755 --- a/README.md +++ b/README.md @@ -71,6 +71,21 @@ if let Some(torrent) = client.get(&target).await? { } ``` +## Adding a torrent + +To upload a torrent/magnet to your backend, use the [`Api::add`] method, which supports +both [`hightorrent::MagnetLink`] via [`api::AddBuilder::magnet`] and +[`hightorrent::TorrentFile`] via [`api::AddBuilder::torrent`]: + +```rust +use hightorrent::MagnetLink; +use hightorrent_api::Api; + + +let magnet = MagnetLink::new("magnet:?xt=urn:btih:2c6e17017f6bb87125b2ba98c56a67f8ffe7e02c&dn=tails-amd64-5.6-img").unwrap(); +client.add().magnet(magnet).send().await?; +``` + # License diff --git a/src/api/add.rs b/src/api/add.rs index 3e9b7c3..bba34bc 100755 --- a/src/api/add.rs +++ b/src/api/add.rs @@ -1,37 +1,36 @@ +use hightorrent::{MagnetLink, TorrentFile}; + use std::boxed::Box; -use std::path::{Path, PathBuf}; use crate::api_error::*; +/// `ApiAdd` is implemented by torrent API clients to add new torrents/magnets to the Bittorrent client. +/// +/// It takes a single `AddBuilder` with an `AddSource`, and adds it to the torrent client, which is +/// a fallible operation. +/// +/// This is the trait that backends should implement, but for convenience, `AddBuilder::send` will +/// perform the operation seamlessly: +/// +/// ```ignore +/// let res = api.add().magnet(magnet_link).send().await; +/// ``` #[async_trait] -/// ApiAdd is implemented by torrent API clients to add new torrents/magnets to the Bittorrent client pub trait ApiAdd<'a>: Send + Sync { async fn api_add_send(&self, add: AddBuilder<'a, AddSource>) -> Result<(), ApiError>; } +/// Dummy newtype used as `AddBuilder` generic type to prevent compilation +/// when no source has been added (typed builder pattern). pub struct NoAddSource; -pub enum AddSource { - MagnetStr(String), - MagnetFile(PathBuf), - TorrentFile(PathBuf), -} - -impl AddSource { - pub fn magnet(s: &str) -> AddSource { - AddSource::MagnetStr(s.to_string()) - } - - pub fn magnet_file(p: &Path) -> AddSource { - AddSource::MagnetFile(p.to_path_buf()) - } - pub fn torrent_file(p: &Path) -> AddSource { - AddSource::TorrentFile(p.to_path_buf()) - } +/// A source for torrent data to add to the Bittorrent backend. +pub enum AddSource { + Magnet(Box), + Torrent(Box), } pub struct AddBuilder<'a, T> { - //api: &'a Box>, api: &'a dyn ApiAdd<'a>, #[allow(dead_code)] pub source: T, @@ -41,11 +40,8 @@ pub struct AddBuilder<'a, T> { } impl<'a> AddBuilder<'a, NoAddSource> { - //pub fn new(api: &'a dyn ApiAdd<'a>) -> Self { - // pub fn new(api: Box<&'a dyn ApiAdd<'a>>) -> Self { pub fn new(api: &'a dyn ApiAdd<'a>) -> Self { AddBuilder { - // api: Box::new(api), api, source: NoAddSource, save_path: None, @@ -56,24 +52,7 @@ impl<'a> AddBuilder<'a, NoAddSource> { } impl<'a> AddBuilder<'a, NoAddSource> { - pub fn magnet(self, s: &'a str) -> AddBuilder<'a, AddSource> { - let Self { - api, - save_path, - paused, - tags, - .. - } = self; - AddBuilder { - api, - source: AddSource::magnet(s), - save_path, - paused, - tags, - } - } - - pub fn magnet_file(self, s: &Path) -> AddBuilder<'a, AddSource> { + pub fn magnet(self, magnet: MagnetLink) -> AddBuilder<'a, AddSource> { let Self { api, save_path, @@ -83,14 +62,14 @@ impl<'a> AddBuilder<'a, NoAddSource> { } = self; AddBuilder { api, - source: AddSource::magnet_file(s), + source: AddSource::Magnet(Box::new(magnet)), save_path, paused, tags, } } - pub fn torrent_file(self, s: &Path) -> AddBuilder<'a, AddSource> { + pub fn torrent(self, torrent: TorrentFile) -> AddBuilder<'a, AddSource> { let Self { api, save_path, @@ -100,7 +79,7 @@ impl<'a> AddBuilder<'a, NoAddSource> { } = self; AddBuilder { api, - source: AddSource::torrent_file(s), + source: AddSource::Torrent(Box::new(torrent)), save_path, paused, tags, diff --git a/src/api/mod.rs b/src/api/mod.rs index 542eaf3..812877a 100755 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,4 +28,21 @@ pub trait Api: Send + Sync + for<'a> ApiAdd<'a> { async fn remove_tracker(&self, hash: &SingleTarget, tracker: &str) -> Result<(), ApiError>; async fn get_files(&self, hash: &SingleTarget) -> Result, ApiError>; + + /// Adds a new torrent to the client backend. + /// + /// This method generates a builder to adjust settings, on which you then + /// need to call the `send` method, like so: + /// + /// ```ignore + /// qbittorrent.add().magnet(magnet_link).send().await?; + /// ``` + /// + /// Additional operation parameters are documented on the [`AddBuilder`] type. + fn add(&self) -> AddBuilder<'_, NoAddSource> + where + Self: Sized, + { + AddBuilder::new(self) + } } diff --git a/src/lib.rs b/src/lib.rs index dd46e56..9dabef4 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,30 @@ //! # Ok(()) //! # } //! ``` +//! +//! # Adding a torrent +//! +//! To upload a torrent/magnet to your backend, use the [`Api::add`] method, which supports +//! both [`hightorrent::MagnetLink`] via [`api::AddBuilder::magnet`] and +//! [`hightorrent::TorrentFile`] via [`api::AddBuilder::torrent`]: +//! +//! ```no_run +//! # use hightorrent_api::QBittorrentClient; +//! use hightorrent::MagnetLink; +//! use hightorrent_api::Api; +//! +//! # async fn run() -> Result<(), hightorrent_api::ApiError> { +//! # let client = QBittorrentClient::login( +//! # "http://localhost:8080", +//! # "admin", +//! # "adminadmin", +//! # ).await?; +//! +//! let magnet = MagnetLink::new("magnet:?xt=urn:btih:2c6e17017f6bb87125b2ba98c56a67f8ffe7e02c&dn=tails-amd64-5.6-img").unwrap(); +//! client.add().magnet(magnet).send().await?; +//! # Ok(()) +//! # } +//! ``` #![allow(rustdoc::redundant_explicit_links)] diff --git a/src/qbittorrent/api.rs b/src/qbittorrent/api.rs index 3f02707..01d25a5 100755 --- a/src/qbittorrent/api.rs +++ b/src/qbittorrent/api.rs @@ -7,7 +7,6 @@ use reqwest::multipart::Part; use reqwest::{Client, ClientBuilder, Response, StatusCode, Url}; use serde::de::DeserializeOwned; use snafu::ResultExt; -use tokio::{fs::File, io::AsyncReadExt}; use std::borrow::Borrow; @@ -170,10 +169,6 @@ impl QBittorrentClient { serde_json::from_slice(&full).context(DeserializationError) } - pub fn add(&self) -> AddBuilder<'_, NoAddSource> { - AddBuilder::new(self) - } - pub async fn list_target(&self, target: &MultiTarget) -> Result { match target { MultiTarget::All => Ok(self.list().await?), @@ -361,90 +356,32 @@ impl Api for QBittorrentClient { #[async_trait] impl<'a> ApiAdd<'a> for QBittorrentClient { async fn api_add_send(&self, add: AddBuilder<'a, AddSource>) -> Result<(), ApiError> { - match add.source { - AddSource::MagnetStr(url) => { - let mut form = Form::new(); - - if let Some(save_path) = add.save_path { - form = form.text("savepath", save_path); - } - - if let Some(paused) = add.paused { - form = form.text("stopped", paused.to_string()); - } - - if let Some(tags) = add.tags { - form = form.text("tags", tags.join(",")); - } - form = form.text("urls", url); - let res = self - ._post_multipart(self._endpoint("torrents/add"), form) - .await?; - add_success(res).await - } - AddSource::MagnetFile(path) => { - let mut form = Form::new(); - - if let Some(save_path) = add.save_path { - form = form.text("savepath", save_path); - } + let mut form = Form::new(); - if let Some(paused) = add.paused { - form = form.text("stopped", paused.to_string()); - } + if let Some(save_path) = add.save_path { + form = form.text("savepath", save_path); + } - if let Some(tags) = add.tags { - form = form.text("tags", tags.join(",")); - } - let content = std::fs::read_to_string(&path).context(FailedReadTorrentError { - path: path.to_path_buf(), - })?; - form = form.text("urls", content); - let res = self - ._post_multipart(self._endpoint("torrents/add"), form) - .await?; - add_success(res).await - } - AddSource::TorrentFile(path) => { - // Form.file() is not supported in async reqwest::multipart::Form - // Snippet copied from https://github.com/seanmonstar/reqwest/issues/646 - let file_name = path - .file_name() - .map(|val| val.to_string_lossy().to_string()) - .unwrap_or_default(); - let mut file = File::open(&path).await.context(FailedReadTorrentError { - path: path.to_path_buf(), - })?; - let mut file_bytes: Vec = Vec::new(); - file.read_to_end(&mut file_bytes) - .await - .context(FailedReadTorrentError { - path: path.to_path_buf(), - })?; - //let reader = Body::wrap_stream(FramedRead::new(file, BytesCodec::new())); - - let mut form = Form::new() - //.part("torrents", Part::stream(reader).file_name(file_name)); - .part("torrents", Part::bytes(file_bytes).file_name(file_name)); - - if let Some(paused) = add.paused { - form = form.text("stopped", paused.to_string()); - } + if let Some(paused) = add.paused { + form = form.text("stopped", paused.to_string()); + } - if let Some(tags) = add.tags { - form = form.text("tags", tags.join(",")); - } + if let Some(tags) = add.tags { + form = form.text("tags", tags.join(",")); + } - if let Some(save_path) = add.save_path { - form = form.text("savepath", save_path); - } + form = match add.source { + AddSource::Magnet(magnet) => form.text("urls", magnet.to_string()), + AddSource::Torrent(torrent) => form.part( + "torrents", + Part::bytes(torrent.to_vec()).file_name(format!("{}.torrent", torrent.id())), + ), + }; - let res = self - ._post_multipart(self._endpoint("torrents/add"), form) - .await?; - add_success(res).await - } - } + let res = self + ._post_multipart(self._endpoint("torrents/add"), form) + .await?; + add_success(res).await } } diff --git a/tests/qbittorrent.rs b/tests/qbittorrent.rs index 0684b2e..5d249e3 100644 --- a/tests/qbittorrent.rs +++ b/tests/qbittorrent.rs @@ -1,4 +1,4 @@ -use hightorrent::SingleTarget; +use hightorrent::{MagnetLink, SingleTarget}; use hightorrent_api::{Api, ApiError, QBittorrentClient}; use tokio::sync::OnceCell; @@ -69,7 +69,11 @@ async fn magnet_v1() -> Result<(), ApiError> { assert!(entry.is_none()); // Add torrent - api.add().magnet(V1_MAGNET).paused(true).send().await?; + api.add() + .magnet(MagnetLink::new(V1_MAGNET).unwrap()) + .paused(true) + .send() + .await?; // Check torrent does exist now let list = api.list().await?;