Compare commits

..

9 Commits

Author SHA1 Message Date
programmer 6d5958df3f Modularize qBittorrent API in separate file 2022-11-03 15:41:59 +01:00
programmer c6d9a0c07d Add replace-tracker/replace-all-trackers commands
API supports get/add/remove_tracker method
Workaround for qbittorrent API dont return name/hash of torrent on "info" method
Patch qbittorrent_web_api API spec to support add_trackers arguments
2022-11-03 15:24:39 +01:00
programmer a144084ac2 API has 4 variants: sync/async * raw/typed 2022-10-30 17:33:35 +01:00
programmer 3002a19b1c Oh no don't clone errors
Clone is not implemented for error sources
2022-10-28 20:28:38 +02:00
programmer 5c7d50a8f5 clonable config 2022-10-27 22:51:02 +02:00
programmer 68360c0286 async api wrapper 2022-10-27 21:22:12 +02:00
programmer 7ebcbab9ac dont warn dead code for test utils 2022-10-27 21:21:18 +02:00
programmer f78257a1fc make library 2022-10-24 17:27:48 +02:00
programmer 7ebbdfc031 Pure nix environment for tests
+ add missing deps for "local" test runner
2022-10-20 01:53:03 +02:00
29 changed files with 812 additions and 184 deletions
+3
View File
@@ -1,3 +1,6 @@
[submodule "vendor/intermodal"]
path = vendor/intermodal
url = https://kl.netlib.re/gitea/programmer/intermodal
[submodule "vendor/qbittorrent_web_api"]
path = vendor/qbittorrent_web_api
url = https://kl.netlib.re/gitea/programmer/qbittorrent_web_api
Generated
+144 -4
View File
@@ -418,6 +418,26 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "enum-iterator"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91a4ec26efacf4aeff80887a175a419493cb6f8b5480d26387eb0bd038976187"
dependencies = [
"enum-iterator-derive",
]
[[package]]
name = "enum-iterator-derive"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "828de45d0ca18782232dfb8f3ea9cc428e8ced380eb26a520baaacfc70de39ce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "env_logger"
version = "0.7.1"
@@ -683,6 +703,19 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac"
dependencies = [
"http",
"hyper",
"rustls",
"tokio",
"tokio-rustls",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@@ -1194,8 +1227,7 @@ dependencies = [
[[package]]
name = "qbittorrent-web-api"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d66c1ef8101cf57f7ca3925ea8306a0e386a4edb6e4d1cfd33d4f92271821b"
source = "git+https://kl.netlib.re/gitea/programmer/qbittorrent_web_api#a0c3fb9d5d86fccc806c95c56361c61c0b62f30a"
dependencies = [
"qbittorrent-web-api-gen",
"reqwest",
@@ -1209,8 +1241,7 @@ dependencies = [
[[package]]
name = "qbittorrent-web-api-gen"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aec126f1ff4af4a2cb0f13af3bbe69f675697b77573c23223f11fa41af93296a"
source = "git+https://kl.netlib.re/gitea/programmer/qbittorrent_web_api#a0c3fb9d5d86fccc806c95c56361c61c0b62f30a"
dependencies = [
"case",
"proc-macro2",
@@ -1230,10 +1261,13 @@ dependencies = [
"argh",
"imdl",
"qbittorrent-web-api",
"regex",
"serde",
"serde_json",
"snafu 0.7.2",
"tokio",
"toml",
"transmission-rpc",
"xdg",
]
@@ -1313,6 +1347,7 @@ dependencies = [
"http",
"http-body",
"hyper",
"hyper-rustls",
"hyper-tls",
"ipnet",
"js-sys",
@@ -1323,25 +1358,65 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"winreg",
]
[[package]]
name = "ring"
version = "0.16.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
dependencies = [
"cc",
"libc",
"once_cell",
"spin",
"untrusted",
"web-sys",
"winapi",
]
[[package]]
name = "rustc-demangle"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
[[package]]
name = "rustls"
version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "539a2bfe908f471bfa933876bd1eb6a19cf2176d375f82ef7f99530a40e48c2c"
dependencies = [
"log",
"ring",
"sct",
"webpki",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55"
dependencies = [
"base64",
]
[[package]]
name = "ryu"
version = "1.0.11"
@@ -1379,6 +1454,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898"
[[package]]
name = "sct"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.7.0"
@@ -1599,6 +1684,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -1840,6 +1931,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [
"rustls",
"tokio",
"webpki",
]
[[package]]
name = "tokio-util"
version = "0.7.4"
@@ -1889,6 +1991,19 @@ dependencies = [
"once_cell",
]
[[package]]
name = "transmission-rpc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180df4216995b3aeb948445d7c297fa8909ab3042e679cd8054251560e651b3b"
dependencies = [
"enum-iterator",
"log",
"reqwest",
"serde",
"serde_repr",
]
[[package]]
name = "try-lock"
version = "0.2.3"
@@ -1943,6 +2058,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "url"
version = "2.3.1"
@@ -2082,6 +2203,25 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "webpki-roots"
version = "0.22.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368bfe657969fb01238bb756d351dcade285e0f6fcbd36dcb23359a5169975be"
dependencies = [
"webpki",
]
[[package]]
name = "winapi"
version = "0.3.9"
+12 -1
View File
@@ -3,17 +3,28 @@ name = "qbt"
version = "0.1.0"
edition = "2021"
[lib]
name = "qbt"
path = "src/lib.rs"
[[bin]]
name = "qbt"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
toml = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
xdg = "2.4"
snafu = "0.7"
qbittorrent-web-api = "0.6"
qbittorrent-web-api = { git = "https://kl.netlib.re/gitea/programmer/qbittorrent_web_api" }
argh = "0.1"
tokio = "1.21"
imdl = { path = "vendor/intermodal" }
regex = "1"
transmission-rpc = "0.4"
[profile.release]
strip = true
+1 -1
View File
@@ -1,6 +1,6 @@
use argh::FromArgs;
use crate::api::ApiClient;
use crate::api::qbittorrent::ApiClient;
use crate::Error;
use crate::action::ActionExec;
use crate::config::Config;
+11 -4
View File
@@ -1,7 +1,7 @@
use argh::FromArgs;
use crate::action::ActionExec;
use crate::api::ApiClient;
use crate::api::qbittorrent::ApiClient;
use crate::config::Config;
use crate::error::Error;
@@ -12,6 +12,9 @@ pub struct GetAction {
#[argh(switch)]
/// the positional argument is a magnet link, not an infohash
magnet: bool,
#[argh(switch, short = 'j')]
/// return parsed JSON response from API
json: bool,
#[argh(positional)]
/// the infohash (or magnet link with --magnet) to retrieve
torrent: String,
@@ -20,10 +23,14 @@ pub struct GetAction {
impl ActionExec for GetAction {
fn exec(&self, config: &Config) -> Result<(), Error> {
let api = ApiClient::from_config(&config)?;
let res = api.get(&self.torrent)?;
if let Some(s) = res {
println!("{}", s)
if let Some(t) = api.get(&self.torrent)? {
if self.json {
println!("{}", &serde_json::to_string(&t).unwrap());
} else {
println!("{}", t.hash);
}
}
Ok(())
}
}
+35
View File
@@ -0,0 +1,35 @@
use argh::FromArgs;
use crate::action::ActionExec;
use crate::api::qbittorrent::ApiClient;
use crate::config::Config;
use crate::error::Error;
#[derive(FromArgs, Debug)]
#[argh(subcommand, name = "get-trackers")]
/// Get the list of trackers on a torrent
/// TODO: Also make list of all trackers?
pub struct GetTrackersAction {
#[argh(switch, short = 'j')]
/// return the list of trackers as a JSON object
json: bool,
#[argh(positional)]
/// the torrent to fetch trackers from
torrent: String,
}
impl ActionExec for GetTrackersAction {
fn exec(&self, config: &Config) -> Result<(), Error> {
let api = ApiClient::from_config(&config)?;
let trackers = api.get_trackers(&self.torrent)?;
if self.json {
println!("{}", serde_json::to_string(&trackers).unwrap());
} else {
for entry in &trackers {
println!("{}", &entry.url);
}
}
Ok(())
}
}
+14 -4
View File
@@ -1,6 +1,6 @@
use argh::FromArgs;
use crate::api::ApiClient;
use crate::api::qbittorrent::ApiClient;
use crate::Error;
use crate::action::ActionExec;
use crate::config::Config;
@@ -8,13 +8,23 @@ use crate::config::Config;
#[derive(FromArgs, PartialEq, Debug)]
#[argh(subcommand, name = "list")]
/// list existing torrents on qBittorrent
pub struct ListAction {}
pub struct ListAction {
#[argh(switch, short = 'j')]
/// return parsed JSON response from API
json: bool,
}
impl ActionExec for ListAction {
fn exec(&self, config: &Config) -> Result<(), Error> {
let api = ApiClient::from_config(&config)?;
let res = api.list()?;
println!("{}", res);
for torrent in api.list()? {
if self.json {
println!("{}", &serde_json::to_string(&torrent).unwrap());
} else {
println!("{}", torrent.hash);
}
}
Ok(())
}
}
+17 -2
View File
@@ -7,6 +7,8 @@ pub mod add;
pub use add::AddAction;
pub mod get;
pub use get::GetAction;
pub mod get_trackers;
pub use get_trackers::GetTrackersAction;
pub mod hash;
pub use hash::HashAction;
pub mod list;
@@ -15,16 +17,23 @@ pub mod magnet;
pub use magnet::MagnetAction;
pub mod name;
pub use name::NameAction;
pub mod replace_tracker;
pub use replace_tracker::ReplaceTrackerAction;
pub mod replace_all_trackers;
pub use replace_all_trackers::ReplaceAllTrackersAction;
#[derive(FromArgs, PartialEq, Debug)]
#[derive(FromArgs, Debug)]
#[argh(subcommand)]
pub enum Action {
Add(AddAction),
List(ListAction),
Get(GetAction),
GetTrackers(GetTrackersAction),
Name(NameAction),
Hash(HashAction),
Magnet(MagnetAction),
ReplaceTracker(ReplaceTrackerAction),
ReplaceAllTrackers(ReplaceAllTrackersAction),
}
pub trait ActionExec {
@@ -40,13 +49,19 @@ impl Action {
cmd.exec(&config)
}, Action::Get(cmd) => {
cmd.exec(&config)
}, Action::GetTrackers(cmd) => {
cmd.exec(&config)
}, Action::Hash(cmd) => {
cmd.exec(&config)
}, Action::Name(cmd) => {
cmd.exec(&config)
}, Action::Magnet(cmd) => {
cmd.exec(&config)
}
}, Action::ReplaceTracker(cmd) => {
cmd.exec(&config)
}, Action::ReplaceAllTrackers(cmd) => {
cmd.exec(&config)
},
}
}
+74
View File
@@ -0,0 +1,74 @@
use argh::FromArgs;
use crate::action::ActionExec;
use crate::api::{TorrentTarget, qbittorrent::ApiClient};
use crate::config::Config;
use crate::error::Error;
#[derive(FromArgs, Debug)]
#[argh(subcommand, name = "replace-all-trackers")]
/// replace all trackers on a torrent or "all" torrents, with the list of trackers given here
pub struct ReplaceAllTrackersAction {
#[argh(positional)]
/// the infohash for which to replace a tracker ; use "all" to replace for all torrents
torrent: TorrentTarget,
#[argh(positional, greedy)]
/// the list of new trackers to set
new_trackers: Vec<String>,
}
impl ActionExec for ReplaceAllTrackersAction {
fn exec(&self, config: &Config) -> Result<(), Error> {
let api = ApiClient::from_config(&config)?;
let mut errors: Vec<Error> = Vec::new();
println!("Adding new trackers: {}", &self.new_trackers.join(","));
// Workaround for weird qBittorrent API
let list = api.list()?;
for entry in self.torrent.to_vec(&api)? {
match api.get_with_cached_list(&entry, &list) {
Ok(found_torrent) => {
let found_torrent = found_torrent.unwrap_or_else(|| panic!("Torrent in 'list' could not be 'get': {}", &entry));
match api.get_trackers(&found_torrent.hash) {
Ok(found_trackers) => {
for found_tracker in found_trackers {
let found_tracker = found_tracker.url;
match api.remove_tracker(&found_torrent.hash, &found_tracker) {
Ok(_) => {},
Err(e) => {
println!("Failed to remove tracker {} for {}", &found_tracker, &found_torrent.hash);
errors.push(e);
},
}
}
for new_tracker in &self.new_trackers {
match api.add_tracker(&found_torrent.hash, new_tracker) {
Ok(()) => {},
Err(e) => {
println!("Failed to add tracker {} for {}", &new_tracker, &found_torrent.hash);
errors.push(e);
}
}
}
}, Err(e) => {
println!("Failed to find trackers for torrent {}", &found_torrent.hash);
errors.push(e);
}
}
}, Err(e) => {
println!("Failed to find info about torrent {}", entry);
errors.push(e);
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Error::MultipleErrors { sources: errors })
}
}
}
+85
View File
@@ -0,0 +1,85 @@
use argh::FromArgs;
use crate::action::ActionExec;
use crate::api::{TorrentTarget, qbittorrent::ApiClient};
use crate::config::Config;
use crate::error::Error;
#[derive(FromArgs, Debug)]
#[argh(subcommand, name = "replace-tracker")]
/// replace trackers on a torrent which match a specific regex ; the new trackers are only
/// added if a match in older trackers was found
pub struct ReplaceTrackerAction {
#[argh(positional)]
/// the infohash for which to replace a tracker ; use "all" to replace for all torrents
torrent: TorrentTarget,
#[argh(positional)]
/// a REGEX matching the tracker(s) to be replaced
old_tracker: regex::Regex,
#[argh(positional, greedy)]
/// the list of new trackers to set
new_trackers: Vec<String>,
}
impl ActionExec for ReplaceTrackerAction {
fn exec(&self, config: &Config) -> Result<(), Error> {
let api = ApiClient::from_config(&config)?;
let mut errors: Vec<Error> = Vec::new();
// Workaround for weird qBittorrent API
let list = api.list()?;
for entry in self.torrent.to_vec(&api)? {
match api.get_with_cached_list(&entry, &list) {
Ok(found_torrent) => {
let found_torrent = found_torrent.unwrap_or_else(|| panic!("Torrent in 'list' could not be 'get': {}", &entry));
match api.get_trackers(&found_torrent.hash) {
Ok(found_trackers) => {
let mut found_matching_tracker = false;
for found_tracker in found_trackers {
let found_tracker = found_tracker.url;
if self.old_tracker.is_match(&found_tracker) {
found_matching_tracker = true;
match api.remove_tracker(&found_torrent.hash, &found_tracker) {
Ok(_) => {},
Err(e) => {
println!("Failed to remove tracker {} for {}", &found_tracker, &found_torrent.hash);
errors.push(e);
},
}
}
}
// add new trackers if there was a matching tracker found
if found_matching_tracker {
for new_tracker in &self.new_trackers {
match api.add_tracker(&found_torrent.hash, new_tracker) {
Ok(()) => {},
Err(e) => {
println!("Failed to add tracker {} for {}", &new_tracker, &found_torrent.hash);
errors.push(e);
}
}
}
}
}, Err(e) => {
println!("Failed to find trackers for torrent {}", &found_torrent.hash);
errors.push(e);
}
}
}, Err(e) => {
println!("Failed to find info about torrent {}", entry);
errors.push(e);
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Error::MultipleErrors { sources: errors })
}
}
}
-161
View File
@@ -1,161 +0,0 @@
use snafu::ResultExt;
use qbittorrent_web_api::Api;
use qbittorrent_web_api::api_impl::Error as QBittorrentError;
use tokio::runtime::Builder;
use crate::config::Config;
use crate::error::{Error, ApiSnafu as IOError, InternalApiSnafu as ApiError};
pub fn blocking_runtime() -> std::io::Result<tokio::runtime::Runtime> {
Builder::new_current_thread()
.enable_all()
.build()
}
#[derive(Debug)]
pub struct UnauthenticatedRawApiClient {
pub rt: tokio::runtime::Runtime,
}
impl UnauthenticatedRawApiClient {
/// Initialize a blocking runtime for the API client
pub fn new() -> Result<UnauthenticatedRawApiClient, std::io::Error> {
Ok(UnauthenticatedRawApiClient {
rt: blocking_runtime()?,
})
}
/// Login into a qBittorrent backend and return a proper ApiClient instance
pub fn login(self, host: &str, login: &str, password: &str) -> Result<RawApiClient, QBittorrentError> {
let api = self.rt.block_on(Api::login(host, login, password))?;
Ok(RawApiClient {
rt: self.rt,
api,
})
}
}
#[derive(Debug)]
pub struct RawApiClient {
rt: tokio::runtime::Runtime,
api: qbittorrent_web_api::api_impl::Authenticated,
}
impl RawApiClient {
pub fn add(&self, magnet: &str, paused: bool) -> Result<String, QBittorrentError> {
let base_call = self.api.torrent_management();
let call = if paused {
base_call.add(magnet).paused("true")
} else {
base_call.add(magnet)
};
self.rt.block_on(call.send_raw())
}
pub fn get(&self, hash: &str) -> Result<String, QBittorrentError> {
self.rt.block_on(self.api.torrent_management().properties_raw(hash))
}
pub fn list(&self) -> Result<String, QBittorrentError> {
self.rt.block_on(self.api.torrent_management().info().send_raw())
}
}
#[derive(Debug)]
/// ApiClient is a convenience struct around qbittorrent_web_api for use in async programs, using qbt::Error error types.
pub struct ApiClient {
pub raw_api: RawApiClient,
}
impl ApiClient {
/// Login to a qBittorrent backend and return a handle to the ApiClient
pub fn new(host: &str, login: &str, password: &str) -> Result<ApiClient, Error> {
let unauthenticated = UnauthenticatedRawApiClient::new().context(IOError)?;
let authenticated = unauthenticated.login(host, login, password).map_err(|e| {
match e {
QBittorrentError::HttpError(_) => {
Error::FailedToReachAPI { source: e }
}, QBittorrentError::InvalidUsernameOrPassword => {
Error::FailedLogin { user: login.to_string() }
} _ => {
panic!("Cookie error");
}
}
})?;
Ok(ApiClient {
raw_api: authenticated,
})
}
pub fn from_config(config: &Config) -> Result<ApiClient, Error> {
Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password)
}
pub fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> {
let res = self.raw_api.add(magnet, paused).context(ApiError)?;
if res == "Ok." {
Ok(())
} else {
Err(Error::message(res.clone()))
}
}
pub fn get(&self, hash: &str) -> Result<Option<String>, Error> {
let res = self.raw_api.get(hash).context(ApiError)?;
if res == "" {
Ok(None)
} else {
Ok(Some(res))
}
}
pub fn list(&self) -> Result<String, Error> {
self.raw_api.list().context(ApiError)
}
}
// TODO: typestate builder https://www.greyblake.com/blog/builder-with-typestate-in-rust/
//struct ApiClientBuilder {}
#[cfg(test)]
mod tests {
use crate::utils::*;
use crate::api::{ApiClient, UnauthenticatedRawApiClient, QBittorrentError};
use crate::error::Error;
#[test]
fn raw_wrong_server() {
let port = find_free_port();
let api = UnauthenticatedRawApiClient::new().expect("IOERROR");
let login = api.login(&format!("http://localhost:{}", &port), "admin", "adminadmin");
assert!(login.is_err());
let err = login.unwrap_err();
match &err {
QBittorrentError::HttpError(_) => {
return;
}, _ => {
println!("{:?}", err);
panic!("API CHANGE!");
}
}
}
#[test]
fn wrong_server() {
let port = find_free_port();
let api = ApiClient::new(&format!("http://localhost:{}", &port), "admin", "adminadmin");
assert!(api.is_err());
let err = api.unwrap_err();
match &err {
Error::FailedToReachAPI { source: _ } => {
return;
}, _ => {
println!("{:?}", err);
panic!("ERROR CONVERSION PROBLEM");
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
pub use crate::api::Torrent;
#[derive(Clone, Serialize, Deserialize)]
pub struct TorrentList(Vec<Torrent>);
impl TorrentList {
pub fn new() -> TorrentList {
TorrentList(Vec::new())
}
pub fn push(&mut self, entry: Torrent) {
self.0.push(entry);
}
pub fn to_vec(self) -> Vec<Torrent> {
self.0
}
}
impl IntoIterator for TorrentList {
type Item = Torrent;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromIterator<Torrent> for TorrentList {
fn from_iter<I: IntoIterator<Item=Torrent>>(iter: I) -> Self {
let mut c = TorrentList::new();
for i in iter {
c.push(i);
}
c
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod qbittorrent;
pub mod torrent;
pub use torrent::{Torrent, IntoTorrent};
pub mod list;
pub use list::TorrentList;
pub mod target;
pub use target::TorrentTarget;
pub mod tracker;
pub use tracker::TorrentTracker;
+94
View File
@@ -0,0 +1,94 @@
use snafu::prelude::*;
use crate::Config;
use crate::api::{Torrent, TorrentList, IntoTorrent, TorrentTracker};
use crate::api::qbittorrent::{QBittorrentListTorrent, RawAsyncApiClient};
use crate::error::{Error, FailedDeserializeSnafu as DeserializeError};
#[derive(Debug)]
pub struct AsyncApiClient {
pub raw_api: RawAsyncApiClient,
}
impl AsyncApiClient {
#[allow(dead_code)]
pub async fn from_config(config: &Config) -> Result<AsyncApiClient, Error> {
Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password).await
}
#[allow(dead_code)]
pub async fn new(host: &str, login: &str, password: &str) -> Result<AsyncApiClient, Error> {
let api = RawAsyncApiClient::login(host, login, password).await?;
Ok(AsyncApiClient {
raw_api: api,
})
}
#[allow(dead_code)]
pub async fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> {
let res = self.raw_api.add(magnet, paused).await?;
if res == "Ok." {
Ok(())
} else {
Err(Error::message(res.clone()))
}
}
#[allow(dead_code)]
pub async fn get(&self, hash: &str) -> Result<Option<Torrent>, Error> {
let res = self.raw_api.get(hash).await?;
if res == "" {
Ok(None)
} else {
// TODO: NOT OPTIMIZED AT ALL. API DOES NOT RETURN NAME/HASH OF TORRENT SO WE HAVE TO QUERY LIST...
let list = self.list().await?;
let filtered_list = list.into_iter().filter(|t| t.hash == hash).collect::<Vec<Torrent>>();
let torrent = filtered_list.first()
.unwrap_or_else(|| panic!("Torrent was 'get' but could not be found in 'list': {}", &hash));
Ok(Some(torrent.clone()))
}
}
#[allow(dead_code)]
/// This method only exists because qBittorrent API 'properties' endpoint does not return torrent name/hash
/// so if you need to do a lot of 'get', please call 'list' once and use this method instead.
pub async fn get_with_cached_list(&self, hash: &str, list: &TorrentList) -> Result<Option<Torrent>, Error> {
let res = self.raw_api.get(hash).await?;
if res == "" {
Ok(None)
} else {
let filtered_list = list.clone().into_iter().filter(|t| t.hash == hash).collect::<Vec<Torrent>>();
let torrent = filtered_list.first()
.unwrap_or_else(|| panic!("Torrent was 'get' but could not be found in 'list': {}", &hash));
Ok(Some(torrent.clone()))
}
}
#[allow(dead_code)]
pub async fn list(&self) -> Result<TorrentList, Error> {
let res = self.raw_api.list().await?;
let concrete: Vec<QBittorrentListTorrent> = serde_json::from_str(&res).context(DeserializeError)?;
Ok(concrete.iter().map(|t| t.into_torrent()).collect())
}
#[allow(dead_code)]
pub async fn add_tracker(&self, hash: &str, url: &str) -> Result<(), Error> {
let _ = self.raw_api.add_tracker(hash, url).await?;
Ok(())
}
#[allow(dead_code)]
pub async fn get_trackers(&self, hash: &str) -> Result<Vec<TorrentTracker>, Error> {
let res = self.raw_api.get_trackers(hash).await?;
let concrete: Vec<TorrentTracker> = serde_json::from_str(&res).context(DeserializeError)?;
let concrete_filter_dht = concrete.into_iter().filter(|t| t.is_tracker()).collect();
Ok(concrete_filter_dht)
}
#[allow(dead_code)]
pub async fn remove_tracker(&self, hash: &str, url: &str) -> Result<(), Error> {
let _ = self.raw_api.remove_tracker(hash, url).await?;
Ok(())
}
}
+10
View File
@@ -0,0 +1,10 @@
mod types;
pub use types::QBittorrentPropertiesTorrent;
pub use types::QBittorrentListTorrent;
mod raw_asynchronous;
pub use raw_asynchronous::RawAsyncApiClient;
mod asynchronous;
pub use asynchronous::AsyncApiClient;
mod synchronous;
pub use synchronous::ApiClient;
+51
View File
@@ -0,0 +1,51 @@
use snafu::prelude::*;
use qbittorrent_web_api::Api;
use crate::error::{Error, InternalApiSnafu as ApiError};
#[derive(Debug)]
pub struct RawAsyncApiClient {
pub api: qbittorrent_web_api::api_impl::Authenticated,
}
impl RawAsyncApiClient {
pub async fn login(host: &str, login: &str, password: &str) -> Result<RawAsyncApiClient, Error> {
Ok(RawAsyncApiClient {
api: Api::login(host, login, password).await.context(ApiError)?,
})
}
#[allow(dead_code)]
pub async fn add(&self, magnet: &str, paused: bool) -> Result<String, Error> {
if paused {
self.api.torrent_management().add(magnet).paused("true").send_raw().await.context(ApiError)
} else {
self.api.torrent_management().add(magnet).send_raw().await.context(ApiError)
}
}
#[allow(dead_code)]
pub async fn get(&self, hash: &str) -> Result<String, Error> {
self.api.torrent_management().properties_raw(hash).await.context(ApiError)
}
#[allow(dead_code)]
pub async fn list(&self) -> Result<String, Error> {
self.api.torrent_management().info().send_raw().await.context(ApiError)
}
#[allow(dead_code)]
pub async fn add_tracker(&self, hash: &str, url: &str) -> Result<String, Error> {
self.api.torrent_management().add_trackers_raw(hash, url).await.context(ApiError)
}
#[allow(dead_code)]
pub async fn get_trackers(&self, hash: &str) -> Result<String, Error> {
self.api.torrent_management().trackers_raw(hash).await.context(ApiError)
}
#[allow(dead_code)]
pub async fn remove_tracker(&self, hash: &str, url: &str) -> Result<String, Error> {
self.api.torrent_management().remove_trackers_raw(hash, &vec!(url)).await.context(ApiError)
}
}
+68
View File
@@ -0,0 +1,68 @@
use snafu::prelude::*;
use tokio::runtime::Runtime;
use crate::{Config, Error};
use crate::api::{Torrent, TorrentList, TorrentTracker};
use crate::api::qbittorrent::AsyncApiClient;
use crate::error::ApiSnafu as IOError;
use crate::utils::blocking_runtime;
#[derive(Debug)]
pub struct ApiClient {
pub rt: Runtime,
pub api: AsyncApiClient,
}
impl ApiClient {
#[allow(dead_code)]
pub fn from_config(config: &Config) -> Result<ApiClient, Error> {
Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password)
}
/// Login into a qBittorrent backend and return a proper ApiClient instance
#[allow(dead_code)]
pub fn new(host: &str, login: &str, password: &str) -> Result<ApiClient, Error> {
let rt = blocking_runtime().context(IOError)?;
let api = rt.block_on(AsyncApiClient::new(host, login, password))?;
Ok(ApiClient {
rt,
api,
})
}
#[allow(dead_code)]
pub fn add(&self, magnet: &str, paused: bool) -> Result<(), Error> {
self.rt.block_on(self.api.add(magnet, paused))
}
#[allow(dead_code)]
pub fn get(&self, hash: &str) -> Result<Option<Torrent>, Error> {
self.rt.block_on(self.api.get(hash))
}
#[allow(dead_code)]
// TODO: Workaround
pub fn get_with_cached_list(&self, hash: &str, list: &TorrentList) -> Result<Option<Torrent>, Error> {
self.rt.block_on(self.api.get_with_cached_list(hash, list))
}
#[allow(dead_code)]
pub fn list(&self) -> Result<TorrentList, Error> {
self.rt.block_on(self.api.list())
}
#[allow(dead_code)]
pub fn add_tracker(&self, hash: &str, url: &str) -> Result<(), Error> {
self.rt.block_on(self.api.add_tracker(hash, url))
}
#[allow(dead_code)]
pub fn get_trackers(&self, hash: &str) -> Result<Vec<TorrentTracker>, Error> {
self.rt.block_on(self.api.get_trackers(hash))
}
#[allow(dead_code)]
pub fn remove_tracker(&self, hash: &str, url: &str) -> Result<(), Error> {
self.rt.block_on(self.api.remove_tracker(hash, url))
}
}
+51
View File
@@ -0,0 +1,51 @@
use crate::api::{Torrent, IntoTorrent};
/// Deserializes from the 'properties' endpoint of QBittorrent API
/// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-generic-properties
#[derive(Clone, Deserialize)]
pub struct QBittorrentPropertiesTorrent {
pub name: String,
#[serde(rename="save_path")]
pub path: String,
#[serde(rename="addition_date")]
pub date_start: i64,
#[serde(rename="completion_date")]
pub date_end: i64,
#[serde(rename="pieces_have")]
pub pieces_have: i64,
#[serde(rename="pieces_num")]
pub pieces_total: i64,
#[serde(rename="total_size")]
pub size: i64,
}
/// Deserializes from the 'info' endpoint of QBittorrent API
/// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list
#[derive(Clone, Deserialize)]
pub struct QBittorrentListTorrent {
pub hash: String,
pub name: String,
#[serde(rename="save_path")]
pub path: String,
#[serde(rename="added_on")]
pub date_start: i64,
#[serde(rename="completion_on")]
pub date_end: i64,
pub progress: f32,
#[serde(rename="total_size")]
pub size: i64,
}
impl IntoTorrent for QBittorrentListTorrent {
fn into_torrent(&self) -> Torrent {
Torrent {
hash: self.hash.to_string(),
name: self.name.to_string(),
path: self.path.to_string(),
date_start: self.date_start,
date_end: self.date_end,
progress: (self.progress * 100.0) as u8,
size: self.size,
}
}
}
+33
View File
@@ -0,0 +1,33 @@
use argh::FromArgValue;
use crate::Error;
use crate::api::qbittorrent::ApiClient;
#[derive(Debug)]
pub enum TorrentTarget {
All,
Hash(String),
}
impl TorrentTarget {
// TODO: replace ApiClient here with a trait so it can be cleanly separated even with async API, or transmission APi
pub fn to_vec(&self, api: &ApiClient) -> Result<Vec<String>, Error> {
match self {
TorrentTarget::All => {
Ok(api.list()?.into_iter().map(|t| t.hash.clone()).collect())
}, TorrentTarget::Hash(s) => {
Ok(vec!(s.clone()))
}
}
}
}
impl FromArgValue for TorrentTarget {
fn from_arg_value(value: &str) -> Result<Self, String> {
if value == "all" {
Ok(TorrentTarget::All)
} else {
Ok(TorrentTarget::Hash(value.to_string()))
}
}
}
+15
View File
@@ -0,0 +1,15 @@
pub trait IntoTorrent {
fn into_torrent(&self) -> Torrent;
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Torrent {
pub hash: String,
pub name: String,
pub path: String,
pub date_start: i64,
pub date_end: i64,
/// Progress percentage (0-100)
pub progress: u8,
pub size: i64,
}
+13
View File
@@ -0,0 +1,13 @@
#[derive(Debug, Serialize, Deserialize)]
pub struct TorrentTracker {
pub url: String,
pub status: usize,
pub msg: String,
}
impl TorrentTracker {
// Filtering out DHT
pub fn is_tracker(&self) -> bool {
! self.url.starts_with("**")
}
}
+2 -2
View File
@@ -6,12 +6,12 @@ use std::path::{Path, PathBuf};
use crate::cli::Cli;
#[derive(Deserialize, Debug)]
#[derive(Clone, Deserialize, Debug)]
pub struct Config {
pub qbittorrent: ClientConfig,
}
#[derive(Deserialize, Debug)]
#[derive(Clone, Deserialize, Debug)]
pub struct ClientConfig {
pub host: String,
pub port: usize,
+4
View File
@@ -34,6 +34,10 @@ pub enum Error {
EmptyNameMagnet { magnet: String },
#[snafu(display("Failed to read file {} because of underlying IO error:\n{}", path.display(), source))]
FailedToReadFile { path: PathBuf, source: std::io::Error },
#[snafu(display("Failed to deserialize JSON response into concrete type:\n{}", source))]
FailedDeserialize { source: serde_json::Error },
#[snafu(display("Failed due to multiple errors:\n{}", sources.iter().map(|e| e.to_string()).collect::<Vec<String>>().join("\n\n")))]
MultipleErrors { sources: Vec<Error> },
}
impl Error {
+12
View File
@@ -0,0 +1,12 @@
#[macro_use] extern crate serde;
pub mod action;
pub mod api;
pub use api::{Torrent, TorrentList, IntoTorrent};
pub mod config;
pub use config::Config;
pub mod cli;
pub use cli::Cli;
pub mod error;
pub use crate::error::{Error, ConfigSnafu as ConfigError};
pub mod utils;
+2
View File
@@ -1,7 +1,9 @@
#[macro_use] extern crate serde;
use snafu::prelude::*;
mod action;
mod api;
pub use api::{Torrent, TorrentList, IntoTorrent};
mod config;
use config::Config;
mod cli;
+8
View File
@@ -1,4 +1,5 @@
use snafu::prelude::*;
use tokio::runtime::{Builder,Runtime};
use imdl::infohash::Infohash;
use imdl::input::Input;
@@ -79,7 +80,14 @@ pub fn torrent_to_magnet<T: AsRef<Path>>(torrent: T) -> Result<String, Error> {
}
// Fallible: only used in tests
#[allow(dead_code)]
pub fn find_free_port() -> u16 {
let bind = TcpListener::bind("127.0.0.1:0").unwrap();
bind.local_addr().unwrap().port()
}
pub fn blocking_runtime() -> std::io::Result<Runtime> {
Builder::new_current_thread()
.enable_all()
.build()
}
+1 -1
View File
@@ -1,6 +1,6 @@
#! /usr/bin/bash
DEPENDENCIES=("qbittorrent-nox" "bats" "curl" "grep" "cat" "sed")
DEPENDENCIES=("qbittorrent-nox" "bats" "curl" "grep" "cat" "sed" "ps" "netstat" "envsubst")
if [[ "${QBTNOX_DIR:-plz}" = "plz" ]]; then
export QBTNOX_DIR="$(mktemp -d)"
+2 -4
View File
@@ -1,8 +1,7 @@
#! /usr/bin/env bash
DEPENDENCIES=("nix" "nix-shell")
# TODO: find grep package name for --pure environment
NIX_DEPENDENCIES=("bats" "curl" "qbittorrent-nox")
NIX_DEPENDENCIES=("bats" "curl" "qbittorrent-nox" "gnugrep" "unixtools.netstat" "envsubst" "ps")
if [[ "${QBTNOX_DIR:-plz}" = "plz" ]]; then
export QBTNOX_DIR="$(mktemp -d)"
@@ -22,8 +21,7 @@ if [[ "$SUBCOMMAND" = "test" ]] && [[ "${IN_NIX_SHELL:-plz}" = "plz" ]]; then
depsCheck
fi
# Are we in nix-shell already?
# TODO: redo --pure when grep dep is found
IN_NIX_SHELL=1 nix-shell -p ${NIX_DEPENDENCIES[@]} --run "\"$ORIGDIR\"/\"$0\" "$@""
IN_NIX_SHELL=1 nix-shell --pure -p ${NIX_DEPENDENCIES[@]} --keep CFGFILE --keep BIN_PATH --keep FREEPORT --run "\"$ORIGDIR\"/\"$0\" "$@""
exit $?
fi
Vendored Submodule
+1