Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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"

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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
```

<!-- cargo-rdme end -->

# License
Expand Down
67 changes: 23 additions & 44 deletions src/api/add.rs
Original file line number Diff line number Diff line change
@@ -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<MagnetLink>),
Torrent(Box<TorrentFile>),
}

pub struct AddBuilder<'a, T> {
//api: &'a Box<dyn ApiAdd<'a>>,
api: &'a dyn ApiAdd<'a>,
#[allow(dead_code)]
pub source: T,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<TorrentContent>, 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)
}
}
24 changes: 24 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down
105 changes: 21 additions & 84 deletions src/qbittorrent/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<TorrentList, Error> {
match target {
MultiTarget::All => Ok(self.list().await?),
Expand Down Expand Up @@ -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<u8> = 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
}
}

Expand Down
8 changes: 6 additions & 2 deletions tests/qbittorrent.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use hightorrent::SingleTarget;
use hightorrent::{MagnetLink, SingleTarget};
use hightorrent_api::{Api, ApiError, QBittorrentClient};
use tokio::sync::OnceCell;

Expand Down Expand Up @@ -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?;
Expand Down
Loading