diff --git a/docs/clients/qBittorrent.md b/docs/clients/qBittorrent.md index 6acd590..9c6baf9 100644 --- a/docs/clients/qBittorrent.md +++ b/docs/clients/qBittorrent.md @@ -18,9 +18,10 @@ download_client = "qBittorrent" # ... [client.qBittorrent] -base_url = "http://localhost:8080" # required -username = "admin" # required -password = "adminadmin" # required +base_url = "http://localhost:8080" # required +username = "admin" # optional +password = "adminadmin" # optional +password_file = "/path/to/password.txt" # optional use_magnet = true # optional, will be true by default savepath = "~/Downloads/" # all optional with no default here and below... category = "Category Name" @@ -40,5 +41,4 @@ sequential_download = true prioritize_first_last_pieces = true ``` -For more information on what each of the values represent, check qBittorrents [WebUI-API documentation](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)). For most users, you will only need the three required parts at the top to get downloads working. - +For more information on what each of the values represent, check qBittorrents [WebUI-API documentation](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)). For most users, you will only need the base URL, username, and password to get downloads working. The password can either be defined in `password_file` (a raw text file containing only the password) or hardcoded in `password`. diff --git a/docs/clients/transmission.md b/docs/clients/transmission.md index 12c3b93..008f4a6 100644 --- a/docs/clients/transmission.md +++ b/docs/clients/transmission.md @@ -22,6 +22,7 @@ download_client = "Transmission" base_url = "http://localhost:9091/transmission/rpc" # required username = "user" # all optional here and below password = "pass" +password_file = "/path/to/password.txt" use_magnet = true labels = [ # must not contain commas in any of the labels "label1", @@ -33,5 +34,7 @@ download_dir = "~/Downloads/" bandwidth_priority = "Low" ``` +The password can either be defined in `password_file` (a raw text file containing only the password) or hardcoded in `password`. + ### Bandwidth Priority This value can be one of the following: `Low`, `Normal`, `High` diff --git a/modules/clients/qBittorrent.nix b/modules/clients/qBittorrent.nix index 91a6248..bcfe62e 100644 --- a/modules/clients/qBittorrent.nix +++ b/modules/clients/qBittorrent.nix @@ -10,17 +10,26 @@ ''; }; username = lib.mkOption { - type = lib.types.str; - default = "admin"; + type = lib.types.nullOr lib.types.str; + default = null; description = '' - The username to login to qBittorrent + The username to login to qBittorrent (optional) ''; }; password = lib.mkOption { - type = lib.types.str; - default = "adminadmin"; + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The password to login to qBittorrent (optional) + Has higher priority than `password_file` + ''; + }; + password_file = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; description = '' - The password to login to qBittorrent + The path to a file containing the password to login to qBittorrent (optional) + Has lower priority than `password` ''; }; use_magnet = lib.mkOption { diff --git a/modules/clients/transmission.nix b/modules/clients/transmission.nix index 7850070..7dc96e8 100644 --- a/modules/clients/transmission.nix +++ b/modules/clients/transmission.nix @@ -20,6 +20,15 @@ default = null; description = '' The password to login to Transmission (optional) + Has higher priority than `password_file` + ''; + }; + password_file = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The path to a file containing the password to login to Transmission (optional) + Has lower priority than `password` ''; }; use_magnet = lib.mkOption { diff --git a/modules/home-manager.nix b/modules/home-manager.nix index 45c0b04..38c26ad 100644 --- a/modules/home-manager.nix +++ b/modules/home-manager.nix @@ -80,6 +80,14 @@ in { ''; }; + hot_reload_config = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to automatically reload config/user-themes once modified + ''; + }; + notifications = { position = lib.mkOption { type = lib.types.nullOr lib.types.str; diff --git a/src/app.rs b/src/app.rs index efcb371..1d6961d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,7 +25,7 @@ use crate::{ source::{ nyaa_html::NyaaHtmlSource, request_client, Item, Source, SourceInfo, SourceResults, Sources, }, - sync::{EventSync, SearchQuery}, + sync::{EventSync, ReloadType, SearchQuery}, theme::{self, Theme}, util::conv::key_to_string, widget::{ @@ -246,13 +246,15 @@ impl App { mpsc::channel::>>(32); let (tx_evt, mut rx_evt) = mpsc::channel::(100); let (tx_dl, mut rx_dl) = mpsc::channel::(100); + let (tx_cfg, mut rx_cfg) = mpsc::channel::(1); tokio::task::spawn(sync.clone().read_event_loop(tx_evt)); + tokio::task::spawn(sync.clone().watch_config_loop(tx_cfg)); match config_manager.load() { Ok(config) => { ctx.failed_config_load = false; - if let Err(e) = config.apply::(&config_manager, ctx, &mut self.widgets) { + if let Err(e) = config.full_apply(config_manager.path(), ctx, &mut self.widgets) { ctx.show_error(e); } else if let Err(e) = ctx.save_config() { ctx.show_error(e); @@ -263,7 +265,7 @@ impl App { if let Err(e) = ctx.config .clone() - .apply::(&config_manager, ctx, &mut self.widgets) + .full_apply(config_manager.path(), ctx, &mut self.widgets) { ctx.show_error(e); } @@ -453,6 +455,50 @@ impl App { } break; } + Some(notif) = rx_cfg.recv() => { + match notif { + ReloadType::Config => { + match config_manager.load() { + Ok(config) => { + match config.partial_apply(ctx, &mut self.widgets) { + Ok(()) => ctx.notify("Reloaded config".to_owned()), + Err(e) => ctx.show_error(e), + } + } + Err(e) => ctx.show_error(e), + } + }, + ReloadType::Theme(t) => match theme::load_user_themes(ctx, config_manager.path()) { + Ok(()) => ctx.notify(format!("Reloaded theme \"{t}\"")), + Err(e) => ctx.show_error(e) + }, + } + //match config_manager.load() { + // Ok(config) => { + // ctx.failed_config_load = false; + // if let Err(e) = config.apply::(&config_manager, ctx, &mut self.widgets) { + // ctx.show_error(e); + // } else { + // ctx.notify(match notif { + // ReloadType::Config => "Reloaded config".to_owned(), + // ReloadType::Theme(theme) => format!("Reloaded theme \"{theme}\""), + // }); + // } + // } + // Err(e) => { + // ctx.show_error(format!("Failed to load config:\n{}", e)); + // if let Err(e) = + // ctx.config + // .clone() + // .apply::(&config_manager, ctx, &mut self.widgets) + // { + // ctx.show_error(e); + // } + // } + //} + + break; + }, // _ = async{}, if matches!(terminal.size().map(|s| self.widgets.notification.update(last_time.map(|l| (Instant::now() - l).as_secs_f64()).unwrap_or(0.), s)), Ok(true)) => { else => { return Err("All channels closed".into()); diff --git a/src/client.rs b/src/client.rs index d429d85..9ce1612 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, VariantArray}; use tokio::task::JoinSet; -use crate::{app::Context, client::cmd::CmdClient, source::Item}; +use crate::{client::cmd::CmdClient, source::Item}; use self::{ cmd::CmdConfig, @@ -256,15 +256,14 @@ impl Client { // ctx.batch.retain(|i| !success_ids.contains(&i.id)); // Remove successes from batch } - pub fn load_config(self, ctx: &mut Context) { + pub fn load_config(self, cfg: &mut ClientConfig) { match self { - Self::Cmd => cmd::load_config(ctx), - Self::Qbit => qbit::load_config(ctx), - Self::Transmission => transmission::load_config(ctx), - Self::Rqbit => rqbit::load_config(ctx), - Self::DefaultApp => default_app::load_config(ctx), - Self::Download => download::load_config(ctx), + Self::Cmd => cmd::load_config(cfg), + Self::Qbit => qbit::load_config(cfg), + Self::Transmission => transmission::load_config(cfg), + Self::Rqbit => rqbit::load_config(cfg), + Self::DefaultApp => default_app::load_config(cfg), + Self::Download => download::load_config(cfg), }; - ctx.config.download_client = self; } } diff --git a/src/client/cmd.rs b/src/client/cmd.rs index 0c4f8ed..03962ee 100644 --- a/src/client/cmd.rs +++ b/src/client/cmd.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{app::Context, source::Item, util::cmd::CommandBuilder}; +use crate::{source::Item, util::cmd::CommandBuilder}; use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -26,9 +26,9 @@ impl Default for CmdConfig { } } -pub fn load_config(app: &mut Context) { - if app.config.client.cmd.is_none() { - app.config.client.cmd = Some(CmdConfig::default()); +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.cmd.is_none() { + cfg.cmd = Some(CmdConfig::default()); } } diff --git a/src/client/default_app.rs b/src/client/default_app.rs index b85b87e..3258d04 100644 --- a/src/client/default_app.rs +++ b/src/client/default_app.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{app::Context, source::Item}; +use crate::source::Item; use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -12,10 +12,10 @@ pub struct DefaultAppConfig { pub struct DefaultAppClient; -pub fn load_config(app: &mut Context) { - if app.config.client.default_app.is_none() { +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.default_app.is_none() { let def = DefaultAppConfig::default(); - app.config.client.default_app = Some(def); + cfg.default_app = Some(def); } } diff --git a/src/client/download.rs b/src/client/download.rs index 0b6e84e..f41632f 100644 --- a/src/client/download.rs +++ b/src/client/download.rs @@ -3,7 +3,7 @@ use std::{error::Error, fs, path::PathBuf}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use crate::{app::Context, source::Item, util::conv::get_hash}; +use crate::{source::Item, util::conv::get_hash}; use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -36,10 +36,9 @@ impl Default for DownloadConfig { } } -pub fn load_config(app: &mut Context) { - if app.config.client.download.is_none() { - let def = DownloadConfig::default(); - app.config.client.download = Some(def); +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.download.is_none() { + cfg.download = Some(DownloadConfig::default()); } } diff --git a/src/client/qbit.rs b/src/client/qbit.rs index 16d07fa..0746383 100644 --- a/src/client/qbit.rs +++ b/src/client/qbit.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; +use std::{collections::HashMap, error::Error, fs}; -use reqwest::{header::REFERER, Response, StatusCode}; +use reqwest::{Response, StatusCode}; use serde::{Deserialize, Serialize}; -use crate::{app::Context, source::Item, util::conv::add_protocol}; +use crate::{source::Item, util::conv::add_protocol}; use super::{ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -11,8 +11,9 @@ use super::{ClientConfig, DownloadClient, DownloadError, DownloadResult}; #[serde(default)] pub struct QbitConfig { pub base_url: String, - pub username: String, - pub password: String, // TODO: introduce password_env and password_cmd for retreiving + pub username: Option, + pub password: Option, + pub password_file: Option, pub use_magnet: Option, pub savepath: Option, pub category: Option, // Single category @@ -56,8 +57,9 @@ impl Default for QbitConfig { fn default() -> Self { Self { base_url: "http://localhost:8080".to_owned(), - username: "admin".to_owned(), - password: "adminadmin".to_owned(), + username: None, + password: None, + password_file: None, use_magnet: None, savepath: None, category: None, @@ -111,45 +113,56 @@ struct QbitForm { // rename: String // Disabled } -async fn login(qbit: &QbitConfig, client: &reqwest::Client) -> Result<(), String> { - let base_url = add_protocol(qbit.base_url.clone(), false); - let url = format!("{}/api/v2/auth/login", base_url); - let mut params = HashMap::new(); - params.insert("username", qbit.username.to_owned()); - params.insert("password", qbit.password.to_owned()); - let res = client.post(url).form(¶ms).send().await; - let _ = res.map_err(|e| format!("Failed to send data to qBittorrent\n{}", e))?; +async fn login( + qbit: &QbitConfig, + client: &reqwest::Client, +) -> Result<(), Box> { + let pass = match qbit.password.as_ref() { + Some(pass) => Some(pass.to_owned()), + None => match qbit.password_file.as_ref() { + Some(file) => { + let contents = fs::read_to_string(file)?; + let expand = shellexpand::full(contents.trim())?; + Some(expand.to_string()) + } + None => None, + }, + }; + if let (Some(user), Some(pass)) = (qbit.username.as_ref(), pass) { + let base_url = add_protocol(qbit.base_url.clone(), false)?; + let url = base_url.join("/api/v2/auth/login")?; + let mut params = HashMap::new(); + params.insert("username", user); + params.insert("password", &pass); + let _ = client.post(url).form(¶ms).send().await?; + } Ok(()) } -async fn logout(qbit: &QbitConfig, client: &reqwest::Client) { - let base_url = add_protocol(qbit.base_url.clone(), false); - let _ = client - .get(format!("{}/api/v2/auth/logout", base_url)) - .header(REFERER, base_url) - .send() - .await; +async fn logout( + qbit: &QbitConfig, + client: &reqwest::Client, +) -> Result<(), Box> { + let base_url = add_protocol(qbit.base_url.clone(), false)?; + let url = base_url.join("/api/v2/auth/logout")?; + let _ = client.get(url).send().await; + Ok(()) } async fn add_torrent( qbit: &QbitConfig, links: String, client: &reqwest::Client, -) -> Result { - let base_url = add_protocol(qbit.base_url.clone(), false); - let url = format!("{}/api/v2/torrents/add", base_url); - - client - .post(url) - .header(REFERER, base_url) - .form(&qbit.to_form(links)) - .send() - .await +) -> Result> { + let base_url = add_protocol(qbit.base_url.clone(), false)?; + let url = base_url.join("/api/v2/torrents/add")?; + + Ok(client.post(url).form(&qbit.to_form(links)).send().await?) } -pub fn load_config(app: &mut Context) { - if app.config.client.qbit.is_none() { - app.config.client.qbit = Some(QbitConfig::default()); +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.qbit.is_none() { + cfg.qbit = Some(QbitConfig::default()); } } @@ -211,7 +224,7 @@ impl DownloadClient for QbitClient { ))); } - logout(&qbit, &client).await; + let _ = logout(&qbit, &client).await; DownloadResult::new( format!("Successfully sent {} torrents to qBittorrent", items.len()), diff --git a/src/client/rqbit.rs b/src/client/rqbit.rs index a0c256d..9861fbd 100644 --- a/src/client/rqbit.rs +++ b/src/client/rqbit.rs @@ -1,10 +1,10 @@ use std::error::Error; -use reqwest::{Response, StatusCode, Url}; +use reqwest::{Response, StatusCode}; use serde::{Deserialize, Serialize}; use urlencoding::encode; -use crate::{app::Context, source::Item, util::conv::add_protocol}; +use crate::{source::Item, util::conv::add_protocol}; use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -40,9 +40,9 @@ async fn add_torrent( conf: &RqbitConfig, link: String, client: &reqwest::Client, -) -> Result> { - let base_url = add_protocol(conf.base_url.clone(), false); - let mut url = Url::parse(&base_url)?.join("/torrents")?; +) -> Result> { + let base_url = add_protocol(conf.base_url.clone(), false)?; + let mut url = base_url.join("/torrents")?; let mut query: Vec = vec![]; if let Some(ow) = conf.overwrite { query.push(format!("overwrite={}", ow)); @@ -58,9 +58,9 @@ async fn add_torrent( } } -pub fn load_config(app: &mut Context) { - if app.config.client.rqbit.is_none() { - app.config.client.rqbit = Some(RqbitConfig::default()); +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.rqbit.is_none() { + cfg.rqbit = Some(RqbitConfig::default()); } } diff --git a/src/client/transmission.rs b/src/client/transmission.rs index d041da9..66cb55a 100644 --- a/src/client/transmission.rs +++ b/src/client/transmission.rs @@ -1,11 +1,12 @@ -use reqwest::Url; +use std::{error::Error, fs}; + use serde::{Deserialize, Serialize}; use transmission_rpc::{ types::{BasicAuth, TorrentAddArgs}, TransClient, }; -use crate::{app::Context, source::Item, util::conv::add_protocol}; +use crate::{source::Item, util::conv::add_protocol}; use super::{multidownload, ClientConfig, DownloadClient, DownloadError, DownloadResult}; @@ -23,6 +24,7 @@ pub struct TransmissionConfig { pub base_url: String, pub username: Option, pub password: Option, + pub password_file: Option, pub use_magnet: Option, pub labels: Option>, pub paused: Option, @@ -39,6 +41,7 @@ impl Default for TransmissionConfig { base_url: "http://localhost:9091/transmission/rpc".to_owned(), username: None, password: None, + password_file: None, use_magnet: None, labels: None, paused: None, @@ -67,26 +70,38 @@ async fn add_torrent( conf: &TransmissionConfig, link: String, client: reqwest::Client, -) -> Result<(), String> { - let base_url = add_protocol(conf.base_url.clone(), false); - let url = match base_url.parse::() { - Ok(url) => url, - Err(e) => return Err(format!("Failed to parse base_url \"{}\":\n{}", base_url, e)), +) -> Result<(), Box> { + let base_url = add_protocol(conf.base_url.clone(), false)?; + let mut client = TransClient::new_with_client(base_url, client); + + let pass = match conf.password.as_ref() { + Some(pass) => Some(pass.to_owned()), + None => match conf.password_file.as_ref() { + Some(file) => { + let contents = fs::read_to_string(file)?; + let expand = shellexpand::full(contents.trim())?; + Some(expand.to_string()) + } + None => None, + }, }; - let mut client = TransClient::new_with_client(url.to_owned(), client); - if let (Some(user), Some(password)) = (conf.username.clone(), conf.password.clone()) { - client.set_auth(BasicAuth { user, password }); + if let (Some(user), Some(password)) = (conf.username.as_ref(), pass) { + client.set_auth(BasicAuth { + user: user.clone(), + password: password.clone(), + }); } let add = conf.clone().to_form(link); - match client.torrent_add(add).await { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to add torrent:\n{}", e)), - } + client + .torrent_add(add) + .await + .map_err(|e| format!("Failed to add torrent:\n{}", e))?; + Ok(()) } -pub fn load_config(app: &mut Context) { - if app.config.client.transmission.is_none() { - app.config.client.transmission = Some(TransmissionConfig::default()); +pub fn load_config(cfg: &mut ClientConfig) { + if cfg.transmission.is_none() { + cfg.transmission = Some(TransmissionConfig::default()); } } @@ -129,7 +144,7 @@ impl DownloadClient for TransmissionClient { client: reqwest::Client, ) -> DownloadResult { multidownload::( - |s| format!("Successfully sent {} torrents to rqbit", s), + |s| format!("Successfully sent {} torrents to Transmission", s), &items, &conf, &client, diff --git a/src/config.rs b/src/config.rs index 031a363..854f294 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,6 +55,7 @@ pub struct Config { pub timeout: u64, pub scroll_padding: usize, pub save_config_on_change: bool, + pub hot_reload_config: bool, #[serde(rename = "notifications")] pub notifications: Option, @@ -77,6 +78,8 @@ impl Default for Config { timeout: 30, scroll_padding: 3, save_config_on_change: true, + hot_reload_config: true, + notifications: None, clipboard: None, client: ClientConfig::default(), @@ -98,30 +101,38 @@ impl ConfigManager for AppConfig { } impl Config { - pub fn apply( + pub fn full_apply( &self, - config_manager: &C, + path: PathBuf, ctx: &mut Context, w: &mut Widgets, ) -> Result<(), Box> { - ctx.config = self.clone(); - w.search.input.cursor = w.search.input.input.len(); - w.sort.selected.sort = 0; - w.filter.selected = 0; + // Load user-defined themes + theme::load_user_themes(ctx, path)?; + + self.partial_apply(ctx, w)?; + + // Set download client ctx.client = ctx.config.download_client; + // Set source ctx.src = ctx.config.source; + // Set source info (categories, etc.) ctx.src_info = ctx.src.info(); - ctx.src.load_config(&mut ctx.config.sources); ctx.src.apply(ctx, w); if let Some(conf) = ctx.config.notifications { w.notification.load_config(&conf); } - ctx.client.load_config(ctx); - let path = config_manager.path(); - // Load user-defined themes - theme::load_user_themes(ctx, path)?; + w.clients.table.select(ctx.client as usize); + + // Load defaults for default source + Ok(()) + } + + pub fn partial_apply(&self, ctx: &mut Context, w: &mut Widgets) -> Result<(), Box> { + ctx.config = self.clone(); + // Set selected theme if let Some((i, _, theme)) = ctx.themes.get_full(&self.theme) { w.theme.selected = i; @@ -129,7 +140,12 @@ impl Config { ctx.theme = theme.clone(); } - // Load defaults for default source + // Load download client config + ctx.client.load_config(&mut ctx.config.client); + + // Load current source config + ctx.src.load_config(&mut ctx.config.sources); + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 5b57fb8..54e3b3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use std::io::stdout; +use std::{error::Error, io::stdout}; use app::App; -use config::AppConfig; +use config::{AppConfig, ConfigManager}; use ratatui::{backend::CrosstermBackend, Terminal}; use sync::AppSync; @@ -21,7 +21,7 @@ struct Args { config_path: Option, } -fn parse_args() -> Result { +fn parse_args() -> Result> { use lexopt::prelude::*; let mut config_path = None; @@ -29,7 +29,7 @@ fn parse_args() -> Result { while let Some(arg) = parser.next()? { match arg { Short('c') | Long("config") => { - config_path = Some(parser.value()?.string()?); + config_path = Some(shellexpand::full(&parser.value()?.string()?)?.to_string()); } Short('v') | Short('V') | Long("version") => { println!("nyaa v{}", env!("CARGO_PKG_VERSION")); @@ -39,7 +39,7 @@ fn parse_args() -> Result { println!("Usage: nyaa [-v|-V|--version] [-c|--config=/path/to/config/folder]"); std::process::exit(0); } - _ => return Err(arg.unexpected()), + _ => return Err(arg.unexpected().into()), } } @@ -62,11 +62,11 @@ async fn main() -> Result<(), Box> { let mut terminal = Terminal::new(backend)?; let mut app = App::default(); - let sync = AppSync {}; let config = match args.config_path { Some(path) => AppConfig::from_path(path), None => AppConfig::new(), }?; + let sync = AppSync::new(config.path()); app.run_app::<_, _, AppConfig, false>(&mut terminal, sync, config) .await?; diff --git a/src/source.rs b/src/source.rs index 89587cf..ca16646 100644 --- a/src/source.rs +++ b/src/source.rs @@ -115,15 +115,17 @@ pub fn request_client( jar: &Arc, timeout: u64, proxy_url: Option, -) -> Result { +) -> Result> { let mut client = reqwest::Client::builder() .gzip(true) .cookie_provider(jar.clone()) .timeout(Duration::from_secs(timeout)); if let Some(proxy_url) = proxy_url { - client = client.proxy(Proxy::all(add_protocol(proxy_url, false))?); + client = client.proxy(Proxy::all( + add_protocol(proxy_url, false).map_err(|e| e.to_string())?, + )?); } - client.build() + Ok(client.build()?) } #[derive(Default, Clone, Copy)] diff --git a/src/source/nyaa_html.rs b/src/source/nyaa_html.rs index d858f93..4774bf3 100644 --- a/src/source/nyaa_html.rs +++ b/src/source/nyaa_html.rs @@ -5,7 +5,7 @@ use ratatui::{ layout::{Alignment, Constraint}, style::{Color, Stylize as _}, }; -use reqwest::{StatusCode, Url}; +use reqwest::StatusCode; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use strum::{Display, FromRepr, VariantArray}; @@ -293,19 +293,18 @@ impl Source for NyaaHtmlSource { .unwrap_or(NyaaSort::Date) .to_url(); - let base_url = add_protocol(nyaa.base_url, true); + let base_url = add_protocol(nyaa.base_url, true)?; + let mut url = base_url.clone(); // let base_url = add_protocol(ctx.config.base_url.clone(), true); let (high, low) = (cat / 10, cat % 10); let query = encode(&search.query); let dir = search.sort.dir.to_url(); - let url = Url::parse(&base_url)?; - let mut url_query = url.clone(); - url_query.set_query(Some(&format!( + url.set_query(Some(&format!( "q={}&c={}_{}&f={}&p={}&s={}&o={}&u={}", query, high, low, filter, page, sort, dir, user ))); - let mut request = client.get(url_query.to_owned()); + let mut request = client.get(url.to_owned()); if let Some(timeout) = nyaa.timeout { request = request.timeout(Duration::from_secs(timeout)); } @@ -313,7 +312,7 @@ impl Source for NyaaHtmlSource { if response.status() != StatusCode::OK { // Throw error if response code is not OK let code = response.status().as_u16(); - return Err(format!("{}\nInvalid response code: {}", url_query, code).into()); + return Err(format!("{}\nInvalid response code: {}", url, code).into()); } let content = response.bytes().await?; let doc = Html::parse_document(std::str::from_utf8(&content[..])?); @@ -380,11 +379,11 @@ impl Source for NyaaHtmlSource { let seeders = as_type(inner(e, seed_sel, "0")).unwrap_or_default(); let leechers = as_type(inner(e, leech_sel, "0")).unwrap_or_default(); let downloads = as_type(inner(e, dl_sel, "0")).unwrap_or_default(); - let torrent_link = url + let torrent_link = base_url .join(&torrent) .map(Into::into) .unwrap_or("null".to_owned()); - let post_link = url + let post_link = base_url .join(&attr(e, title_sel, "href")) .map(Into::into) .unwrap_or("null".to_owned()); diff --git a/src/source/nyaa_rss.rs b/src/source/nyaa_rss.rs index b86a4d3..4253c56 100644 --- a/src/source/nyaa_rss.rs +++ b/src/source/nyaa_rss.rs @@ -1,7 +1,7 @@ use std::{cmp::Ordering, collections::BTreeMap, error::Error, str::FromStr, time::Duration}; use chrono::{DateTime, Local}; -use reqwest::{StatusCode, Url}; +use reqwest::StatusCode; use rss::{extension::Extension, Channel}; use urlencoding::encode; @@ -53,8 +53,7 @@ pub async fn search_rss( let last_page = 1; let (high, low) = (cat / 10, cat % 10); let query = encode(&query); - let base_url = add_protocol(base_url, true); - let base_url = Url::parse(&base_url)?; + let base_url = add_protocol(base_url, true)?; let mut url = base_url.clone(); let query = format!( diff --git a/src/source/sukebei_nyaa.rs b/src/source/sukebei_nyaa.rs index 2971be0..d349afa 100644 --- a/src/source/sukebei_nyaa.rs +++ b/src/source/sukebei_nyaa.rs @@ -2,7 +2,7 @@ use std::{error::Error, time::Duration}; use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; use ratatui::style::Color; -use reqwest::{StatusCode, Url}; +use reqwest::StatusCode; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use strum::VariantArray as _; @@ -160,12 +160,11 @@ impl Source for SukebeiHtmlSource { .unwrap_or(NyaaSort::Date) .to_url(); - let base_url = add_protocol(sukebei.base_url, true); + let base_url = add_protocol(sukebei.base_url, true)?; let (high, low) = (cat / 10, cat % 10); let query = encode(&search.query); let dir = search.sort.dir.to_url(); - let url = Url::parse(&base_url)?; - let mut url_query = url.clone(); + let mut url_query = base_url.clone(); url_query.set_query(Some(&format!( "q={}&c={}_{}&f={}&p={}&s={}&o={}&u={}", query, high, low, filter, page, sort, dir, user @@ -219,7 +218,7 @@ impl Source for SukebeiHtmlSource { let icon = cat.icon.clone(); let torrent = attr(e, torrent_sel, "href"); - let post_link = url + let post_link = base_url .join(&attr(e, title_sel, "href")) .map(Into::into) .unwrap_or("null".to_owned()); @@ -243,7 +242,7 @@ impl Source for SukebeiHtmlSource { let seeders = inner(e, seed_sel, "0").parse().unwrap_or(0); let leechers = inner(e, leech_sel, "0").parse().unwrap_or(0); let downloads = inner(e, dl_sel, "0").parse().unwrap_or(0); - let torrent_link = url + let torrent_link = base_url .join(&torrent) .map(Into::into) .unwrap_or("null".to_owned()); diff --git a/src/source/torrent_galaxy.rs b/src/source/torrent_galaxy.rs index fadb410..a9d0555 100644 --- a/src/source/torrent_galaxy.rs +++ b/src/source/torrent_galaxy.rs @@ -253,7 +253,7 @@ fn get_url( base_url: String, search: &SearchQuery, ) -> Result<(Url, Url), Box> { - let base_url = Url::parse(&add_protocol(base_url, true))?.join("torrents.php")?; + let base_url = add_protocol(base_url, true)?.join("torrents.php")?; let query = encode(&search.query); diff --git a/src/sync.rs b/src/sync.rs index 365dd08..9ffee73 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,4 +1,9 @@ -use std::error::Error; +use std::{ + error::Error, + fs, + path::PathBuf, + time::{Duration, SystemTime}, +}; use crossterm::event::{self, Event}; use tokio::sync::mpsc; @@ -6,9 +11,10 @@ use tokio::sync::mpsc; use crate::{ app::LoadType, client::{Client, ClientConfig, DownloadResult}, + config::CONFIG_FILE, results::Results, source::{Item, SourceConfig, SourceResponse, SourceResults, Sources}, - theme::Theme, + theme::{Theme, THEMES_PATH}, widget::sort::SelectedSort, }; @@ -38,10 +44,22 @@ pub trait EventSync { self, tx_evt: mpsc::Sender, ) -> impl std::future::Future + std::marker::Send + 'static; + fn watch_config_loop( + self, + tx_evt: mpsc::Sender, + ) -> impl std::future::Future + std::marker::Send + 'static; } #[derive(Clone)] -pub struct AppSync; +pub struct AppSync { + config_path: PathBuf, +} + +impl AppSync { + pub fn new(config_path: PathBuf) -> Self { + Self { config_path } + } +} #[derive(Clone, Default)] pub struct SearchQuery { @@ -53,6 +71,23 @@ pub struct SearchQuery { pub user: Option, } +#[derive(Clone)] +pub enum ReloadType { + Config, + Theme(String), +} + +fn watch(path: &PathBuf, last_modified: SystemTime) -> bool { + if let Ok(meta) = fs::metadata(path) { + if let Ok(time) = meta.modified() { + if time > last_modified { + return true; + } + } + } + false +} + impl EventSync for AppSync { async fn load_results( self, @@ -104,4 +139,37 @@ impl EventSync for AppSync { } } } + + async fn watch_config_loop(self, tx_cfg: mpsc::Sender) { + let config_path = self.config_path.clone(); + let config_file = config_path.join(CONFIG_FILE); + let themes_path = config_path.join(THEMES_PATH); + let now = SystemTime::now(); + + let mut last_modified = now; + loop { + if watch(&config_file, last_modified) { + last_modified = SystemTime::now(); + let _ = tx_cfg.send(ReloadType::Config).await; + } + let theme_files = fs::read_dir(&themes_path).ok().and_then(|v| { + v.filter_map(Result::ok) + .map(|v| v.path()) + .find(|p| watch(&p, last_modified)) + }); + if let Some(theme) = theme_files { + last_modified = SystemTime::now(); + let _ = tx_cfg + .send(ReloadType::Theme( + theme + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + )) + .await; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } } diff --git a/src/theme.rs b/src/theme.rs index 3f340c4..6dcbf94 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{app::Context, collection, config, source::SourceTheme}; +pub static THEMES_PATH: &str = "themes"; + #[derive(Clone, Serialize, Deserialize)] pub struct Theme { pub name: String, @@ -42,7 +44,7 @@ pub struct Theme { } pub fn load_user_themes(ctx: &mut Context, config_path: PathBuf) -> Result<(), String> { - let path = config_path.join("themes"); + let path = config_path.join(THEMES_PATH); if !path.exists() { return Ok(()); // Allow no theme folder } diff --git a/src/util/conv.rs b/src/util/conv.rs index 9dac34d..0a63b14 100644 --- a/src/util/conv.rs +++ b/src/util/conv.rs @@ -1,4 +1,7 @@ +use std::error::Error; + use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; +use reqwest::Url; pub fn get_hash(magnet: String) -> Option { magnet @@ -6,18 +9,21 @@ pub fn get_hash(magnet: String) -> Option { .and_then(|m| m.1.split_once('&').map(|m| m.0.to_owned())) } -pub fn add_protocol>(url: S, default_https: bool) -> String { +pub fn add_protocol>( + url: S, + default_https: bool, +) -> Result> { let url = url.into(); if let Some((method, other)) = url.split_once(':') { if matches!(method, "http" | "https" | "socks5") && matches!(other.get(..2), Some("//")) { - return url; + return Ok(url.parse::()?); } } let protocol = match default_https { true => "https", false => "http", }; - format!("{}://{}", protocol, url) + Ok(format!("{}://{}", protocol, url).parse::()?) } pub fn to_bytes(size: &str) -> usize { diff --git a/src/widget/clients.rs b/src/widget/clients.rs index 5c89c34..6b1a5e6 100644 --- a/src/widget/clients.rs +++ b/src/widget/clients.rs @@ -69,8 +69,9 @@ impl Widget for ClientsPopup { KeyCode::Enter => { if let Some(c) = self.table.selected() { ctx.client = *c; + ctx.config.download_client = *c; - c.load_config(ctx); + c.load_config(&mut ctx.config.client); match ctx.save_config() { Ok(_) => ctx.notify(format!("Updated download client to \"{}\"", c)), Err(e) => ctx.show_error(format!("Failed to update config:\n{}", e)), diff --git a/src/widget/notifications.rs b/src/widget/notifications.rs index 5940493..cd62f08 100644 --- a/src/widget/notifications.rs +++ b/src/widget/notifications.rs @@ -94,10 +94,10 @@ impl NotificationWidget { } pub fn update(&mut self, deltatime: f64, area: Rect) -> bool { - let res = self.notifs.iter_mut().fold(false, |acc, x| { - let res = x.update(deltatime, area); - res || acc - }); + let res = self + .notifs + .iter_mut() + .fold(false, |acc, x| x.update(deltatime, area) || acc); let finished = self .notifs .iter() @@ -106,7 +106,7 @@ impl NotificationWidget { false => None, }) .collect::>(); - // Offset unfinished notifications by space left from finished notifs + // Offset unfinished notifications by gap left from finished notifs for (offset, height) in finished.iter() { self.notifs.iter_mut().for_each(|n| { if n.is_error() && n.offset() > *offset { @@ -122,9 +122,7 @@ impl NotificationWidget { impl Widget for NotificationWidget { fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - for n in self.notifs.iter_mut() { - n.draw(f, ctx, area); - } + self.notifs.iter_mut().for_each(|n| n.draw(f, ctx, area)); } fn handle_event(&mut self, _ctx: &mut Context, _e: &Event) {} diff --git a/src/widget/results.rs b/src/widget/results.rs index cf6b237..4440958 100644 --- a/src/widget/results.rs +++ b/src/widget/results.rs @@ -1,3 +1,5 @@ +use core::str; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Margin, Rect}, @@ -16,9 +18,16 @@ use crate::{ use super::{border_block, centered_rect, Corner, VirtualStatefulTable}; +#[derive(Clone, Copy, PartialEq, Eq)] +enum VisualMode { + Toggle, + Select, + None, // TODO: Remove for just Option +} + pub struct ResultsWidget { pub table: VirtualStatefulTable, - control_space: bool, + control_space_toggle: VisualMode, visual_anchor: usize, // draw_count: u64, } @@ -29,6 +38,14 @@ impl ResultsWidget { *self.table.state.offset_mut() = 0; } + fn try_select(&self, ctx: &mut Context, sel: usize) { + if let Some(item) = ctx.results.response.items.get(sel) { + if ctx.batch.iter().position(|s| s.id == item.id).is_none() { + ctx.batch.push(item.to_owned()); + } + } + } + fn try_select_toggle(&self, ctx: &mut Context, sel: usize) { if let Some(item) = ctx.results.response.items.get(sel) { if let Some(p) = ctx.batch.iter().position(|s| s.id == item.id) { @@ -38,13 +55,39 @@ impl ResultsWidget { } } } + + fn try_select_toggle_range(&self, ctx: &mut Context, start: usize, stop: usize) { + for i in start..=stop { + self.try_select_toggle(ctx, i); + } + } + + fn select_on_move(&self, ctx: &mut Context, start: usize, stop: usize) { + if start != stop { + let sel = match stop <= self.visual_anchor { + true => start, + false => stop, + }; + match self.control_space_toggle { + VisualMode::Toggle => self.try_select_toggle(ctx, sel), + //VisualMode::Select => self.try_select(ctx, sel), + VisualMode::Select => { + if stop.abs_diff(self.visual_anchor) < start.abs_diff(self.visual_anchor) { + self.try_select_toggle(ctx, start); + } + self.try_select(ctx, stop); + } + _ => {} + } + } + } } impl Default for ResultsWidget { fn default() -> Self { ResultsWidget { table: VirtualStatefulTable::new(), - control_space: false, + control_space_toggle: VisualMode::None, visual_anchor: 0, // draw_count: 0, } @@ -222,28 +265,21 @@ impl super::Widget for ResultsWidget { (Char('j') | KeyCode::Down, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); let selected = self.table.next(ctx.results.response.items.len(), 1); - if self.control_space && prev != selected { - self.try_select_toggle( - ctx, - match selected <= self.visual_anchor { - true => prev, - false => selected, - }, - ); - } + self.select_on_move(ctx, prev, selected); } (Char('k') | KeyCode::Up, &KeyModifiers::NONE) => { let prev = self.table.selected().unwrap_or(0); let selected = self.table.next(ctx.results.response.items.len(), -1); - if self.control_space && prev != selected { - self.try_select_toggle( - ctx, - match selected >= self.visual_anchor { - true => prev, - false => selected, - }, - ); - } + self.select_on_move(ctx, selected, prev); + //if self.control_space_toggle.is_some() && prev != selected { + // self.try_select_toggle( + // ctx, + // match selected >= self.visual_anchor { + // true => prev, + // false => selected, + // }, + // ); + //} } (Char('J'), &KeyModifiers::SHIFT) => { self.table.next(ctx.results.response.items.len(), 4); @@ -252,8 +288,20 @@ impl super::Widget for ResultsWidget { self.table.next(ctx.results.response.items.len(), -4); } (Char('G'), &KeyModifiers::SHIFT) => { - self.table - .select(ctx.results.response.items.len().saturating_sub(1)); + let prev = self.table.selected().unwrap_or(0); + let selected = ctx.results.response.items.len().saturating_sub(1); + self.table.select(selected); + + if self.control_space_toggle != VisualMode::None && prev != selected { + self.try_select_toggle_range(ctx, prev + 1, selected); + //self.try_select_toggle( + // ctx, + // match selected <= self.visual_anchor { + // true => prev, + // false => selected, + // }, + //); + } } (Char('g'), &KeyModifiers::NONE) => { self.table.select(0); @@ -300,9 +348,26 @@ impl super::Widget for ResultsWidget { } } (Char('y'), &KeyModifiers::NONE) => ctx.mode = Mode::KeyCombo("y".to_string()), - (Char(' '), &KeyModifiers::CONTROL) => { - self.control_space = !self.control_space; - if self.control_space { + (Char(' '), &KeyModifiers::CONTROL) | (Char('v'), &KeyModifiers::NONE) => { + self.control_space_toggle = match self.control_space_toggle { + VisualMode::None => VisualMode::Toggle, + _ => VisualMode::None, + }; + if self.control_space_toggle != VisualMode::None { + ctx.notify("Entered VISUAL mode"); + self.visual_anchor = self.table.selected().unwrap_or(0); + self.try_select_toggle(ctx, self.visual_anchor); + } else { + ctx.notify("Exited VISUAL mode"); + self.visual_anchor = 0; + } + } + (Char('V'), &KeyModifiers::SHIFT) => { + self.control_space_toggle = match self.control_space_toggle { + VisualMode::None => VisualMode::Select, + _ => VisualMode::None, + }; + if self.control_space_toggle != VisualMode::None { ctx.notify("Entered VISUAL mode"); self.visual_anchor = self.table.selected().unwrap_or(0); self.try_select_toggle(ctx, self.visual_anchor); @@ -326,10 +391,10 @@ impl super::Widget for ResultsWidget { ctx.mode = Mode::Batch; } (Esc, &KeyModifiers::NONE) => { - if self.control_space { + if self.control_space_toggle != VisualMode::None { ctx.notify("Exited VISUAL mode"); self.visual_anchor = 0; - self.control_space = false; + self.control_space_toggle = VisualMode::None; } else { ctx.dismiss_notifications(); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fef73f9..71383d5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -7,7 +7,7 @@ use nyaa::{ config::{Config, ConfigManager}, results::Results, source::{Item, SourceResults}, - sync::EventSync, + sync::{EventSync, ReloadType}, }; use ratatui::{ backend::{Backend as _, TestBackend}, @@ -15,6 +15,7 @@ use ratatui::{ style::Style, Terminal, }; +use tokio::sync::mpsc::Sender; #[derive(Clone)] pub struct TestSync { @@ -161,9 +162,7 @@ pub fn print_buffer(buf: &Buffer) { impl EventSync for TestSync { async fn load_results( self, - tx_res: tokio::sync::mpsc::Sender< - Result>, - >, + tx_res: Sender>>, _loadtype: nyaa::app::LoadType, _src: nyaa::source::Sources, _client: reqwest::Client, @@ -177,7 +176,7 @@ impl EventSync for TestSync { .await; } - async fn read_event_loop(self, tx_evt: tokio::sync::mpsc::Sender) { + async fn read_event_loop(self, tx_evt: Sender) { for evt in self.events.into_iter() { let _ = tx_evt.send(evt).await; } @@ -186,7 +185,7 @@ impl EventSync for TestSync { async fn download( self, - _tx_dl: tokio::sync::mpsc::Sender, + _tx_dl: Sender, _batch: bool, _items: Vec, _config: ClientConfig, @@ -194,6 +193,8 @@ impl EventSync for TestSync { _client: Client, ) { } + + async fn watch_config_loop(self, _tx_evt: Sender) {} } impl ConfigManager for TestConfig {