first commit
This commit is contained in:
commit
9097a18926
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "vendor/intermodal"]
|
||||||
|
path = vendor/intermodal
|
||||||
|
url = https://kl.netlib.re/gitea/programmer/intermodal
|
2175
Cargo.lock
generated
Normal file
2175
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "qbt"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# 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"] }
|
||||||
|
xdg = "2.4"
|
||||||
|
snafu = "0.7"
|
||||||
|
qbittorrent-web-api = "0.6"
|
||||||
|
argh = "0.1"
|
||||||
|
tokio = "1.21"
|
||||||
|
imdl = { path = "vendor/intermodal" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
|
#opt-level = "z"
|
22
src/action/add.rs
Normal file
22
src/action/add.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
use tokio::task;
|
||||||
|
use qbittorrent_web_api::Api;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "add")]
|
||||||
|
/// add a torrent to qBittorrent (only magnet for the moment)
|
||||||
|
pub struct AddAction {
|
||||||
|
#[argh(positional)]
|
||||||
|
/// the magnet link to add
|
||||||
|
magnet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionExec for AddAction {
|
||||||
|
fn exec(&self, _config: &Config) -> Result<(), Error> {
|
||||||
|
unimplemented!();
|
||||||
|
}
|
||||||
|
}
|
29
src/action/get.rs
Normal file
29
src/action/get.rs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::api::ApiClient;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "get")]
|
||||||
|
/// get info about a specific torrent on qBittorrent
|
||||||
|
pub struct GetAction {
|
||||||
|
#[argh(switch)]
|
||||||
|
/// the positional argument is a magnet link, not an infohash
|
||||||
|
magnet: bool,
|
||||||
|
#[argh(positional)]
|
||||||
|
/// the infohash (or magnet link with --magnet) to retrieve
|
||||||
|
torrent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
28
src/action/hash.rs
Normal file
28
src/action/hash.rs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::utils::{magnet_hash, torrent_hash};
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "hash")]
|
||||||
|
/// get infohash from magnet/torrent
|
||||||
|
pub struct HashAction {
|
||||||
|
#[argh(positional)]
|
||||||
|
/// the magnet link or torrent file to parse
|
||||||
|
torrent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionExec for HashAction {
|
||||||
|
fn exec(&self, _config: &Config) -> Result<(), Error> {
|
||||||
|
// TODO errors
|
||||||
|
let hash = if self.torrent.starts_with("magnet:") {
|
||||||
|
magnet_hash(&self.torrent)
|
||||||
|
} else {
|
||||||
|
torrent_hash(&self.torrent)
|
||||||
|
};
|
||||||
|
println!("{}", hash);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
20
src/action/list.rs
Normal file
20
src/action/list.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::api::ApiClient;
|
||||||
|
use crate::Error;
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "list")]
|
||||||
|
/// list existing torrents on qBittorrent
|
||||||
|
pub struct ListAction {}
|
||||||
|
|
||||||
|
impl ActionExec for ListAction {
|
||||||
|
fn exec(&self, config: &Config) -> Result<(), Error> {
|
||||||
|
let api = ApiClient::from_config(&config)?;
|
||||||
|
let res = api.list()?;
|
||||||
|
println!("{}", res);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
26
src/action/magnet.rs
Normal file
26
src/action/magnet.rs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::utils::torrent_to_magnet;
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "magnet")]
|
||||||
|
/// produce magnet link from torrent file
|
||||||
|
pub struct MagnetAction {
|
||||||
|
#[argh(positional)]
|
||||||
|
/// the magnet torrent file to parse
|
||||||
|
torrent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionExec for MagnetAction {
|
||||||
|
fn exec(&self, _config: &Config) -> Result<(), Error> {
|
||||||
|
if self.torrent.starts_with("magnet:") {
|
||||||
|
println!("{}", &self.torrent);
|
||||||
|
} else {
|
||||||
|
println!("{}", torrent_to_magnet(&self.torrent));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
53
src/action/mod.rs
Normal file
53
src/action/mod.rs
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub mod add;
|
||||||
|
pub use add::AddAction;
|
||||||
|
pub mod get;
|
||||||
|
pub use get::GetAction;
|
||||||
|
pub mod hash;
|
||||||
|
pub use hash::HashAction;
|
||||||
|
pub mod list;
|
||||||
|
pub use list::ListAction;
|
||||||
|
pub mod magnet;
|
||||||
|
pub use magnet::MagnetAction;
|
||||||
|
pub mod name;
|
||||||
|
pub use name::NameAction;
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand)]
|
||||||
|
pub enum Action {
|
||||||
|
Add(AddAction),
|
||||||
|
List(ListAction),
|
||||||
|
Get(GetAction),
|
||||||
|
Name(NameAction),
|
||||||
|
Hash(HashAction),
|
||||||
|
Magnet(MagnetAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ActionExec {
|
||||||
|
fn exec(&self, config: &Config) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn exec(&self, config: &Config) -> Result<(), Error> {
|
||||||
|
match self {
|
||||||
|
Action::Add(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}, Action::List(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}, Action::Get(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}, Action::Hash(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}, Action::Name(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}, Action::Magnet(cmd) => {
|
||||||
|
cmd.exec(&config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
27
src/action/name.rs
Normal file
27
src/action/name.rs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::action::ActionExec;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::utils::{magnet_name, torrent_name};
|
||||||
|
|
||||||
|
#[derive(FromArgs, PartialEq, Debug)]
|
||||||
|
#[argh(subcommand, name = "name")]
|
||||||
|
/// get name from magnet/torrent
|
||||||
|
pub struct NameAction {
|
||||||
|
#[argh(positional)]
|
||||||
|
/// the magnet link or torrent file to parse
|
||||||
|
torrent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionExec for NameAction {
|
||||||
|
fn exec(&self, _config: &Config) -> Result<(), Error> {
|
||||||
|
let name = if self.torrent.starts_with("magnet:") {
|
||||||
|
magnet_name(&self.torrent)
|
||||||
|
} else {
|
||||||
|
torrent_name(&self.torrent)
|
||||||
|
};
|
||||||
|
println!("{}", name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
48
src/api.rs
Normal file
48
src/api.rs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
use snafu::ResultExt;
|
||||||
|
use qbittorrent_web_api::Api;
|
||||||
|
use tokio::runtime::Builder;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::{Error, ApiSnafu as IOError, InternalApiSnafu as ApiError};
|
||||||
|
|
||||||
|
pub struct ApiClient {
|
||||||
|
pub rt: tokio::runtime::Runtime,
|
||||||
|
pub api: qbittorrent_web_api::api_impl::Authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient {
|
||||||
|
pub fn from_config(config: &Config) -> Result<ApiClient, Error> {
|
||||||
|
Self::new(&config.format_host(), &config.qbittorrent.login, &config.qbittorrent.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(host: &str, login: &str, password: &str) -> Result<ApiClient, Error> {
|
||||||
|
let rt = Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build().context(IOError)?;
|
||||||
|
// Call the asynchronous connect method using the runtime.
|
||||||
|
let api = rt.block_on(Api::login(host, &login, &password)).context(ApiError)?;
|
||||||
|
Ok(ApiClient {
|
||||||
|
rt,
|
||||||
|
api,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, hash: &str) -> Result<Option<String>, Error> {
|
||||||
|
let res = self.rt.block_on(self.api.torrent_management().properties_raw(hash)).context(ApiError)?;
|
||||||
|
if res == "" {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Result<String, Error> {
|
||||||
|
Ok(
|
||||||
|
self.rt.block_on(self.api.torrent_management().info().send_raw()).context(ApiError)?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: typestate builder https://www.greyblake.com/blog/builder-with-typestate-in-rust/
|
||||||
|
//struct ApiClientBuilder {}
|
16
src/cli.rs
Normal file
16
src/cli.rs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
|
||||||
|
#[derive(FromArgs, Debug)]
|
||||||
|
/// interact with your qBittorrent instance from the command line
|
||||||
|
pub struct Cli {
|
||||||
|
#[argh(subcommand)]
|
||||||
|
pub command: Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
pub fn from_args() -> Cli {
|
||||||
|
argh::from_env()
|
||||||
|
}
|
||||||
|
}
|
59
src/config.rs
Normal file
59
src/config.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use snafu::prelude::*;
|
||||||
|
use xdg::{BaseDirectories, BaseDirectoriesError};
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub qbittorrent: ClientConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ClientConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: usize,
|
||||||
|
pub login: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[snafu(display("Invalid configuration file format at: {path}"))]
|
||||||
|
Config { source: toml::de::Error, path: String },
|
||||||
|
#[snafu(display("Failed to read configuration file {path}"))]
|
||||||
|
IO { source: std::io::Error, path: String },
|
||||||
|
#[snafu(display("Failed to look up $XDG_CONFIG_DIR"))]
|
||||||
|
XDG { source: BaseDirectoriesError },
|
||||||
|
#[snafu(display("Configuration file not found in $XDG_CONFIG_DIR/qbt/.qbt.toml"))]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn default_path() -> Result<PathBuf, ConfigError> {
|
||||||
|
let xdg_dirs = BaseDirectories::with_prefix("qbt").context(XDGSnafu)?;
|
||||||
|
if let Some(f) = xdg_dirs.find_config_file(".qbt.toml") {
|
||||||
|
Ok(f)
|
||||||
|
} else {
|
||||||
|
Err(ConfigError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_path<T: AsRef<Path>>(path: T) -> Result<Config, ConfigError> {
|
||||||
|
let path_str = path.as_ref().to_str().expect("Unicode error");
|
||||||
|
let config_str = std::fs::read_to_string(&path).context(IOSnafu { path: path_str })?;
|
||||||
|
Ok(toml::from_str(&config_str).context(ConfigSnafu { path: path_str } )?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_default_path() -> Result<Config, ConfigError> {
|
||||||
|
Ok(Self::from_path(&Self::default_path()?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_host(&self) -> String {
|
||||||
|
if self.qbittorrent.host.starts_with("http") {
|
||||||
|
format!("{}:{}", self.qbittorrent.host, self.qbittorrent.port)
|
||||||
|
} else {
|
||||||
|
format!("http://{}:{}", self.qbittorrent.host, self.qbittorrent.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
src/error.rs
Normal file
19
src/error.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use snafu::prelude::*;
|
||||||
|
//use snafu::*;
|
||||||
|
|
||||||
|
use crate::config::ConfigError;
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(visibility(pub))]
|
||||||
|
/// The possible error cases
|
||||||
|
pub enum Error {
|
||||||
|
#[snafu(display("Configuration file reading or parsing error"))]
|
||||||
|
Config { source: ConfigError },
|
||||||
|
#[snafu(display("Torrent/magnet reading or parsing error"))]
|
||||||
|
Imdl { source: imdl::error::Error },
|
||||||
|
#[snafu(display("qBittorrent API communication error"))]
|
||||||
|
Api { source: std::io::Error },
|
||||||
|
#[snafu(display("Internal qBittorrent API error"))]
|
||||||
|
InternalApi { source: qbittorrent_web_api::api_impl::Error },
|
||||||
|
//GenericIOError(std::io::Error),
|
||||||
|
}
|
20
src/main.rs
Normal file
20
src/main.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
use snafu::prelude::*;
|
||||||
|
|
||||||
|
mod action;
|
||||||
|
mod api;
|
||||||
|
mod config;
|
||||||
|
use config::Config;
|
||||||
|
mod cli;
|
||||||
|
use cli::Cli;
|
||||||
|
mod error;
|
||||||
|
use crate::error::{Error, ConfigSnafu as ConfigError};
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Error> {
|
||||||
|
let config = Config::from_default_path().context(ConfigError)?;
|
||||||
|
let cli = Cli::from_args();
|
||||||
|
// TODO: Make ActionExec type return Option<String>? where None means no data was found and so we abort with a proper exit code
|
||||||
|
cli.command.exec(&config)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
68
src/utils.rs
Normal file
68
src/utils.rs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
use imdl::infohash::Infohash;
|
||||||
|
use imdl::input::Input;
|
||||||
|
use imdl::input_target::InputTarget;
|
||||||
|
use imdl::magnet_link::MagnetLink;
|
||||||
|
use imdl::metainfo::Metainfo;
|
||||||
|
use imdl::torrent_summary::TorrentSummary;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Helper method for imdl functions which expect an imdl::Input type
|
||||||
|
pub fn input_path<T: AsRef<Path>>(path: T) -> Result<Input, std::io::Error> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let absolute = path.canonicalize()?;
|
||||||
|
let data = std::fs::read(absolute)?;
|
||||||
|
Ok(Input::new(
|
||||||
|
InputTarget::Path(path.to_path_buf()),
|
||||||
|
data
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the infohash of a magnet link
|
||||||
|
pub fn magnet_hash<T: AsRef<str>>(magnet: T) -> String {
|
||||||
|
let magnet = MagnetLink::parse(magnet.as_ref()).expect("Parsing magnet failed");
|
||||||
|
//Ok(magnet.name.expect("Magnet link has no name!"))
|
||||||
|
//Ok(String::from_utf8_lossy(&magnet.infohash.inner.bytes).to_string())
|
||||||
|
let bytes = magnet.infohash.inner.bytes.clone();
|
||||||
|
let mut s = String::new();
|
||||||
|
for byte in bytes {
|
||||||
|
s.push_str(&format!("{:02x}", byte));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the name of a magnet link
|
||||||
|
pub fn magnet_name<T: AsRef<str>>(magnet: T) -> String {
|
||||||
|
let magnet = MagnetLink::parse(magnet.as_ref()).expect("Parsing magnet failed");
|
||||||
|
magnet.name.expect("Magnet link has no name!")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the infohash of a torrent file
|
||||||
|
pub fn torrent_hash<T: AsRef<Path>>(torrent: T) -> String {
|
||||||
|
let input = input_path(torrent).unwrap();
|
||||||
|
TorrentSummary::from_input(&input).expect("Parsing torrent file failed").torrent_summary_data().info_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the name of a torrent file
|
||||||
|
pub fn torrent_name<T: AsRef<Path>>(torrent: T) -> String {
|
||||||
|
let input = input_path(torrent).unwrap();
|
||||||
|
TorrentSummary::from_input(&input).expect("Parsing torrent file failed").torrent_summary_data().name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a torrent file into a magnet link
|
||||||
|
pub fn torrent_to_magnet<T: AsRef<Path>>(torrent: T) -> String {
|
||||||
|
let input = input_path(torrent).expect("Failed to read torrent file");
|
||||||
|
let infohash = Infohash::from_input(&input).expect("Failed to parse infohash");
|
||||||
|
let metainfo = Metainfo::from_input(&input).expect("Failed to parse meta info");
|
||||||
|
|
||||||
|
let mut link = MagnetLink::with_infohash(infohash);
|
||||||
|
|
||||||
|
link.set_name(&metainfo.info.name);
|
||||||
|
|
||||||
|
for result in metainfo.trackers() {
|
||||||
|
link.add_tracker(result.expect("failed tracker in metainfo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
link.to_url().to_string()
|
||||||
|
|
||||||
|
}
|
1
vendor/intermodal
vendored
Submodule
1
vendor/intermodal
vendored
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit ea3b22fcccc7aecaf28f8005478a1ad957332a30
|
Loading…
Reference in New Issue
Block a user