commit
11a333bef4
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
result*
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,2 @@
|
|||
[workspace]
|
||||
members = [ "crates/*" ]
|
|
@ -0,0 +1,12 @@
|
|||
YOLO LICENSE
|
||||
Version 1, July 10 2015
|
||||
|
||||
THIS SOFTWARE LICENSE IS PROVIDED "ALL CAPS" SO THAT YOU KNOW IT IS SUPER
|
||||
SERIOUS AND YOU DON'T MESS AROUND WITH COPYRIGHT LAW BECAUSE YOU WILL GET IN
|
||||
TROUBLE HERE ARE SOME OTHER BUZZWORDS COMMONLY IN THESE THINGS WARRANTIES
|
||||
LIABILITY CONTRACT TORT LIABLE CLAIMS RESTRICTION MERCHANTABILITY SUBJECT TO
|
||||
THE FOLLOWING CONDITIONS:
|
||||
|
||||
1. #yolo
|
||||
2. #swag
|
||||
3. #blazeit
|
|
@ -0,0 +1 @@
|
|||
/target
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "lena"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
.idea
|
|
@ -0,0 +1,27 @@
|
|||
stages:
|
||||
- test
|
||||
- deploy-dryrun
|
||||
- deploy
|
||||
|
||||
Build and test Rust code:
|
||||
stage: test
|
||||
image: rustlang/rust:nightly
|
||||
script:
|
||||
- cargo test
|
||||
|
||||
Test publishing crate:
|
||||
stage: deploy-dryrun
|
||||
image: rustlang/rust:nightly
|
||||
script:
|
||||
- cargo publish --dry-run
|
||||
only:
|
||||
- tags
|
||||
|
||||
Publish crate:
|
||||
stage: deploy
|
||||
image: rustlang/rust:nightly
|
||||
script:
|
||||
- sh -c 'cargo login "${CARGO_TOKEN}" && cargo publish'
|
||||
only:
|
||||
- tags
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "transmission_rs"
|
||||
version = "0.5.0"
|
||||
authors = ["Mike Cronce <mike@cronce.io>"]
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
repository = "https://gitlab.cronce.io/foss/transmission-rs"
|
||||
categories = ["api-bindings"]
|
||||
keywords = ["torrent", "bittorrent", "transmission"]
|
||||
description = "A safe, ergonomic, async client for the Transmission BitTorrent client implemented in pure Rust"
|
||||
|
||||
[dependencies]
|
||||
reqwest = {version = "0.11", features = ["gzip", "json"]}
|
||||
serde = {version = "1", features = ["derive"]}
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
tokio = "1"
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2018 Steven vanZyl
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,5 @@
|
|||
# transmission-rs
|
||||
Ergonomic Rust bindings for the [Transmission](https://transmissionbt.com/) BitTorrent client implemented in pure Rust.
|
||||
|
||||
Documentation coming Soon(TM)
|
||||
|
Binary file not shown.
|
@ -0,0 +1,179 @@
|
|||
//! Client for download management.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryInto;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::Error;
|
||||
mod payload;
|
||||
use payload::Request;
|
||||
use payload::Response;
|
||||
mod list;
|
||||
use list::FieldList;
|
||||
use list::TorrentList;
|
||||
pub mod torrent;
|
||||
use torrent::Torrent;
|
||||
use torrent::TorrentFile;
|
||||
use torrent::AddTorrent;
|
||||
use torrent::AddTorrentResponse;
|
||||
use torrent::GetTorrentFilesRequest;
|
||||
use torrent::GetTorrentFilesResponse;
|
||||
//use torrent::AddedTorrent;
|
||||
|
||||
/// Interface into the major functions of Transmission
|
||||
/// including adding, and removing torrents.
|
||||
///
|
||||
/// The `Client` does not keep track of the created torrents itself.
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
tls: bool,
|
||||
auth: Option<(String, String)>,
|
||||
|
||||
base_url: String,
|
||||
session_id: Option<String>,
|
||||
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
// TODO: Take Option<(&str, &str)> for auth
|
||||
pub fn new(host: impl ToString, port: u16, tls: bool, auth: Option<(String, String)>) -> Result<Self, Error> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::USER_AGENT, "transmission-rs/0.1".try_into()?);
|
||||
let this = Self{
|
||||
host: host.to_string(),
|
||||
port: port,
|
||||
tls: tls,
|
||||
auth: auth,
|
||||
base_url: {
|
||||
let protocol = match tls {
|
||||
true => "https",
|
||||
false => "http"
|
||||
};
|
||||
format!("{}://{}:{}", protocol, host.to_string(), port)
|
||||
},
|
||||
session_id: None,
|
||||
http: reqwest::Client::builder()
|
||||
.gzip(true)
|
||||
.default_headers(headers)
|
||||
.build()?
|
||||
};
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
fn build_url(&self, path: impl AsRef<str>) -> String {
|
||||
format!("{}{}", self.base_url, path.as_ref()).to_string()
|
||||
}
|
||||
|
||||
pub async fn authenticate(&mut self) -> Result<(), Error> {
|
||||
self.session_id = None;
|
||||
let response = self.post("/transmission/rpc/")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send()
|
||||
.await?;
|
||||
self.session_id = match response.headers().get("X-Transmission-Session-Id") {
|
||||
Some(v) => Some(v.to_str()?.to_string()),
|
||||
None => None // TODO: Return an error
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn post(&self, path: impl AsRef<str>) -> reqwest::RequestBuilder {
|
||||
let url = self.build_url(path);
|
||||
//println!("{}", url);
|
||||
let mut request = self.http.post(&url);
|
||||
request = match &self.auth {
|
||||
Some(auth) => request.basic_auth(&auth.0, Some(&auth.1)),
|
||||
None => request
|
||||
};
|
||||
request = match &self.session_id {
|
||||
Some(token) => request.header("X-Transmission-Session-Id", token),
|
||||
None => request
|
||||
};
|
||||
request
|
||||
}
|
||||
|
||||
pub async fn rpc(&mut self, method: impl ToString, tag: u8, body: impl Serialize) -> Result<reqwest::Response, Error> {
|
||||
let request = Request::new(method, tag, body);
|
||||
let mut error = None;
|
||||
for i in 0..5 {
|
||||
sleep(Duration::from_secs(i)).await;
|
||||
let result = self.post("/transmission/rpc/")
|
||||
// Content-Type doesn't actually appear to be necessary, and is
|
||||
// technically a lie, since there's no key/value being passed,
|
||||
// just some JSON, but the official client sends this, so we
|
||||
// will too.
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(serde_json::to_string(&request)?)
|
||||
.send().await?
|
||||
.error_for_status();
|
||||
match result {
|
||||
Ok(r) => return Ok(r),
|
||||
Err(e) => match e.status() {
|
||||
Some(reqwest::StatusCode::CONFLICT) => {
|
||||
self.authenticate().await?;
|
||||
error = Some(e);
|
||||
continue;
|
||||
},
|
||||
_ => return Err(e.into())
|
||||
}
|
||||
};
|
||||
}
|
||||
match error {
|
||||
Some(e) => Err(e.into()),
|
||||
// Should be unreachable
|
||||
None => Err(Error::HTTPUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&mut self) -> Result<Vec<Torrent>, Error> {
|
||||
let field_list = FieldList::from_vec(vec![
|
||||
"error",
|
||||
"errorString",
|
||||
"eta",
|
||||
"id",
|
||||
"isFinished",
|
||||
"leftUntilDone",
|
||||
"name",
|
||||
"peersGettingFromUs",
|
||||
"peersSendingToUs",
|
||||
"rateDownload",
|
||||
"rateUpload",
|
||||
"sizeWhenDone",
|
||||
"status",
|
||||
"uploadRatio"
|
||||
]);
|
||||
let response: Response<TorrentList> = self.rpc("torrent-get", 4, field_list).await?.error_for_status()?.json().await?;
|
||||
Ok(response.arguments.torrents)
|
||||
}
|
||||
|
||||
// TODO: Borrow key from value
|
||||
pub async fn list_by_name(&mut self) -> Result<BTreeMap<String, Torrent>, Error> {
|
||||
Ok(self.list().await?.into_iter().map(|v| (v.name.clone(), v)).collect::<BTreeMap<_, _>>())
|
||||
}
|
||||
|
||||
pub async fn add_torrent_from_link(&mut self, url: impl ToString) -> Result<AddTorrentResponse, Error> {
|
||||
let response: Response<AddTorrentResponse> = self.rpc("torrent-add", 8, AddTorrent{filename: url.to_string()}).await?.error_for_status()?.json().await?;
|
||||
Ok(response.arguments)
|
||||
}
|
||||
|
||||
pub async fn get_files_by_id(&mut self, id: u16) -> Result<Vec<TorrentFile>, Error> {
|
||||
let response: Response<GetTorrentFilesResponse> = self.rpc("torrent-get", 3, GetTorrentFilesRequest::new(id)).await?.error_for_status()?.json().await?;
|
||||
if(response.arguments.torrents.len() == 0) {
|
||||
// TODO: Maybe make the Ok result an Option? Or maybe return a 404 error?
|
||||
return Ok(vec![]);
|
||||
} else if(response.arguments.torrents.len() > 1) {
|
||||
return Err(Error::WeirdGetFilesResponse);
|
||||
}
|
||||
// TODO: Figure out how to move files out of this structure, since we're discarding the rest anyway, instead of cloning
|
||||
Ok(response.arguments.torrents[0].files.clone())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::torrent::Torrent;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TorrentList {
|
||||
pub torrents: Vec<Torrent>
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FieldList {
|
||||
pub fields: Vec<String>
|
||||
}
|
||||
|
||||
impl FieldList {
|
||||
pub fn from_vec(vec: Vec<impl ToString>) -> Self {
|
||||
Self{
|
||||
fields: vec.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Request<T> {
|
||||
method: String,
|
||||
tag: u8,
|
||||
pub arguments: T
|
||||
}
|
||||
|
||||
impl<T> Request<T> {
|
||||
pub fn new(method: impl ToString, tag: u8, arguments: T) -> Self {
|
||||
Self{
|
||||
method: method.to_string(),
|
||||
tag: tag,
|
||||
arguments: arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Response<T> {
|
||||
result: String,
|
||||
tag: u8,
|
||||
pub arguments: T
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Torrent {
|
||||
pub id: u16,
|
||||
pub error: u8,
|
||||
pub error_string: String,
|
||||
pub eta: i64, // TODO: Option<u64> with -1 as None
|
||||
pub is_finished: bool,
|
||||
pub left_until_done: u64, // TODO: u32?
|
||||
pub name: String,
|
||||
pub peers_getting_from_us: u16,
|
||||
pub peers_sending_to_us: u16,
|
||||
pub rate_download: u32,
|
||||
pub rate_upload: u32,
|
||||
pub size_when_done: u64,
|
||||
pub status: u8,
|
||||
pub upload_ratio: f32
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AddTorrent {
|
||||
pub filename: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddedTorrent {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub hash_string: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AddTorrentResponse {
|
||||
TorrentAdded(AddedTorrent),
|
||||
TorrentDuplicate(AddedTorrent)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct GetTorrentFilesRequest {
|
||||
fields: Vec<String>,
|
||||
ids: u16
|
||||
}
|
||||
|
||||
impl GetTorrentFilesRequest {
|
||||
pub fn new(id: u16) -> Self {
|
||||
Self{
|
||||
fields: vec!["files".to_string()],
|
||||
ids: id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TorrentFile {
|
||||
pub name: PathBuf,
|
||||
pub bytes_completed: u64,
|
||||
pub length: u64
|
||||
}
|
||||
|
||||
impl TorrentFile {
|
||||
pub fn take_name(self) -> PathBuf {
|
||||
self.name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TorrentFileWrapper {
|
||||
pub files: Vec<TorrentFile>
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetTorrentFilesResponse {
|
||||
pub torrents: Vec<TorrentFileWrapper>
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("HTTP error")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
#[error("HTTP header decoding error")]
|
||||
ReqwestHeaderDecode(#[from] reqwest::header::ToStrError),
|
||||
#[error("invalid HTTP header value")]
|
||||
ReqwestHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
|
||||
#[error("Unknown HTTP failure")]
|
||||
HTTPUnknown,
|
||||
#[error("JSON error")]
|
||||
JSON(#[from] serde_json::Error),
|
||||
#[error("got more than one 'torrents' entry in a get files response")]
|
||||
WeirdGetFilesResponse,
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#![allow(unused_parens)]
|
||||
//! Ergonomic Rust bindings for the [Transmission](https://transmissionbt.com/) BitTorrent client
|
||||
//! based on [transmission-sys](https://gitlab.com/tornado-torrent/transmission-sys).
|
||||
//!
|
||||
//! Most interaction will be done through the `Client` struct.
|
||||
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
|
||||
// Re-exports
|
||||
mod client;
|
||||
pub use client::Client;
|
||||
pub use client::torrent::Torrent;
|
||||
pub use client::torrent::AddTorrentResponse;
|
||||
pub use client::torrent::TorrentFile;
|
||||
mod error;
|
||||
pub use error::Error;
|
||||
|
Loading…
Reference in New Issue