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