first commit

This commit is contained in:
programmer programmer 2022-10-11 16:48:15 +02:00
commit 9097a18926
18 changed files with 2636 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

3
.gitmodules vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

21
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

@ -0,0 +1 @@
Subproject commit ea3b22fcccc7aecaf28f8005478a1ad957332a30