initial commit

Signed-off-by: Xe <me@christine.website>
This commit is contained in:
Cadey Ratio 2021-10-21 12:27:47 -04:00
commit 11a333bef4
21 changed files with 1480 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
eval "$(lorri direnv)"

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
result*

1016
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

2
Cargo.toml Normal file
View File

@ -0,0 +1,2 @@
[workspace]
members = [ "crates/*" ]

12
LICENSE Normal file
View File

@ -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

1
crates/lena/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

8
crates/lena/Cargo.toml Normal file
View File

@ -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]

3
crates/lena/src/main.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

5
crates/transmission-rs/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/target
**/*.rs.bk
Cargo.lock
.idea

View File

@ -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

View File

@ -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"

View File

@ -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.

View File

@ -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.

View File

@ -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())
}
}

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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,
}

View File

@ -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;

11
shell.nix Normal file
View File

@ -0,0 +1,11 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
rustc cargo rustfmt rls rust-analyzer
pkg-config sqlite openssl
# keep this line if you use bash
pkgs.bashInteractive
];
}