diff --git a/Cargo.lock b/Cargo.lock index 5a530c7..5c8044a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,6 +1296,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ntfy" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bb106c0f23b713c6472d05eae4a8a8fd9b3e57ed4c4c68bce5c92875ceeea1" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.15", + "serde", + "serde_json", + "url", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1490,7 +1503,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.12", "tokio", "tracing", "web-time", @@ -1510,7 +1523,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.12", "tinyvec", "tracing", "web-time", @@ -1592,6 +1605,7 @@ dependencies = [ "log", "magnet-url", "multimap", + "ntfy", "pretty_env_logger", "regex", "reqwest 0.12.15", @@ -1613,7 +1627,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -1724,6 +1738,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-socks", "tower", "tower-service", "url", @@ -2104,13 +2119,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2237,6 +2272,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -2417,6 +2464,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3642381..2c36b63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ console = "0.15.8" reqwest = "0.12.15" magnet-url = "2.0.0" urlencoding = "2.1.3" +ntfy = "0.7.0" diff --git a/src/config.rs b/src/config.rs index 0741960..53bec51 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use crate::actions::bitmagnet::BitmagnetConfig; use crate::actions::transmission::TransmissionConfig; use crate::args::Args; +use crate::notifications::ntfy::NtfyConfig; use color_eyre::eyre::{eyre, Result, WrapErr}; use directories::ProjectDirs; use figment::providers::Env; @@ -31,6 +32,9 @@ pub struct Config { #[serde(default)] pub transmission: Option, + #[serde(default)] + pub ntfy: Option, + #[serde(default)] pub sources: HashMap, } diff --git a/src/main.rs b/src/main.rs index 624f841..691ba45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ use crate::args::Args; use crate::config::{get_db_path, load_config}; use crate::db::Database; use crate::magnet::{extract_magnet_links, Magnet}; +use crate::notifications::notification::Notification; +use crate::notifications::ntfy::NtfyNotification; use chrono::{DateTime, Utc}; use clap::Parser; use color_eyre::eyre::{eyre, Result, WrapErr}; @@ -21,6 +23,7 @@ mod config; mod db; mod magnet; mod models; +mod notifications; mod reddit_client; mod report; mod schema; @@ -172,6 +175,28 @@ async fn main() -> Result<()> { debug!("No Transmission configuration found"); } + // Initialize notifications + let mut notifications: Vec> = Vec::new(); + + // Add Ntfy notification if enabled + if let Some(ntfy_config) = conf.ntfy { + if ntfy_config.enable { + info!("Initializing Ntfy notification"); + match NtfyNotification::new(ntfy_config) { + Ok(ntfy_notification) => { + notifications.push(Box::new(ntfy_notification)); + } + Err(e) => { + warn!("Failed to initialize Ntfy notification: {}", e); + } + } + } else { + debug!("Ntfy notification is disabled"); + } + } else { + debug!("No Ntfy configuration found"); + } + // Process all actions and collect results let mut action_results = HashMap::new(); @@ -194,7 +219,27 @@ async fn main() -> Result<()> { } } - for line in report::generate_report(&action_results, total_new_links).lines() { + // Send notifications + for notification in ¬ifications { + match notification + .send_notification(&action_results, total_new_links) + .await + { + Ok(_) => { + debug!("Successfully sent notification to {}", notification.name()); + } + Err(e) => { + warn!( + "Failed to send notification to {}: {}", + notification.name(), + e + ); + } + } + } + + // Generate and display report + for line in report::generate_report(&action_results, total_new_links, true).lines() { info!("{}", line); } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs new file mode 100644 index 0000000..9b278a1 --- /dev/null +++ b/src/notifications/mod.rs @@ -0,0 +1,2 @@ +pub mod notification; +pub mod ntfy; diff --git a/src/notifications/notification.rs b/src/notifications/notification.rs new file mode 100644 index 0000000..71be9cb --- /dev/null +++ b/src/notifications/notification.rs @@ -0,0 +1,31 @@ +use crate::actions::action::ProcessedMagnets; +use async_trait::async_trait; +use color_eyre::eyre::Result; +use std::collections::HashMap; + +/// Trait for notification services +#[async_trait] +pub trait Notification { + /// Return the name of the notification service + fn name(&self) -> &str; + + /// Send a notification about the processing results + async fn send_notification( + &self, + action_results: &HashMap, + total_new_links: usize, + ) -> Result<()>; + + /// Determine if a notification should be sent based on the results + fn should_notify( + &self, + action_results: &HashMap, + total_new_links: usize, + ) -> bool { + // Don't send notification if everything was successful but 0 links were grabbed and processed + if total_new_links == 0 && action_results.values().all(|pm| pm.failed.is_empty()) { + return false; + } + true + } +} diff --git a/src/notifications/ntfy/config.rs b/src/notifications/ntfy/config.rs new file mode 100644 index 0000000..99bb9c3 --- /dev/null +++ b/src/notifications/ntfy/config.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration for the ntfy notification service +#[derive(Debug, Serialize, Deserialize)] +pub struct NtfyConfig { + /// Whether to enable the ntfy notification service + #[serde(default)] + pub enable: bool, + + /// The host URL of the ntfy server + pub host: String, + + /// The username for authentication (optional) + #[serde(default)] + pub username: Option, + + /// The password for authentication (optional) + #[serde(default)] + pub password: Option, + + /// The topic to publish notifications to + pub topic: String, +} diff --git a/src/notifications/ntfy/mod.rs b/src/notifications/ntfy/mod.rs new file mode 100644 index 0000000..03ca59c --- /dev/null +++ b/src/notifications/ntfy/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod notification; + +pub use config::NtfyConfig; +pub use notification::NtfyNotification; diff --git a/src/notifications/ntfy/notification.rs b/src/notifications/ntfy/notification.rs new file mode 100644 index 0000000..54b306b --- /dev/null +++ b/src/notifications/ntfy/notification.rs @@ -0,0 +1,68 @@ +use crate::actions::action::ProcessedMagnets; +use crate::notifications::notification::Notification; +use crate::notifications::ntfy::config::NtfyConfig; +use crate::report; +use async_trait::async_trait; +use color_eyre::eyre::Result; +use log::{debug, info}; +use ntfy::prelude::*; +use std::collections::HashMap; + +/// Notification service using ntfy +pub struct NtfyNotification { + config: NtfyConfig, + client: Dispatcher, +} + +impl NtfyNotification { + /// Create a new NtfyNotification instance + pub fn new(config: NtfyConfig) -> Result { + let mut builder = dispatcher::builder(config.host.clone()); + + // Add authentication if provided + if let (Some(username), Some(password)) = (&config.username, &config.password) { + builder = builder.credentials(Auth::credentials(username, password)) + } + + let client = builder.build_async()?; + + Ok(NtfyNotification { config, client }) + } +} + +#[async_trait] +impl Notification for NtfyNotification { + fn name(&self) -> &str { + "Ntfy" + } + + async fn send_notification( + &self, + action_results: &HashMap, + total_new_links: usize, + ) -> Result<()> { + if !self.should_notify(action_results, total_new_links) { + debug!("Skipping notification as there's nothing to report"); + return Ok(()); + } + + let priority = if action_results.values().any(|pm| !pm.failed.is_empty()) { + Priority::High + } else { + Priority::Default + }; + + let payload = Payload::new(&self.config.topic) + .message(report::generate_report( + action_results, + total_new_links, + false, + )) + .priority(priority); + + self.client.send(&payload).await?; + + info!("Sent notification to ntfy topic: {}", self.config.topic); + Ok(()) + } +} diff --git a/src/report.rs b/src/report.rs index 3da1112..6288278 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,5 +1,4 @@ use crate::actions::action::ProcessedMagnets; -use color_eyre::eyre::Result; use console::{style, Emoji}; use std::collections::HashMap; @@ -17,10 +16,23 @@ fn pluralize(count: usize, singular: &str, plural: &str) -> String { } } +/// Helper function to conditionally apply style +/// Note that passing `true` doesn't enforce colours - console-rs/console has some logic to disable +/// them automagically. +fn conditional_style(text: &str, use_colors: bool) -> console::StyledObject<&str> { + let styled = style(text); + if use_colors { + styled + } else { + styled.force_styling(false) + } +} + /// Generate a report of processed magnets pub fn generate_report( action_results: &HashMap, total_new_links: usize, + use_colors: bool, ) -> String { let mut report = String::new(); @@ -28,11 +40,16 @@ pub fn generate_report( report.push_str(&format!( "{} {} {}\n", SPARKLES, - style("Report Summary").bold().underlined(), - style(format!( - "{} added to the database", - pluralize(total_new_links, "new link", "new links") - )) + conditional_style("Report Summary", use_colors) + .bold() + .underlined(), + conditional_style( + &format!( + "{} added to the database", + pluralize(total_new_links, "new link", "new links") + ), + use_colors + ) .bold() .green(), )); @@ -48,11 +65,14 @@ pub fn generate_report( report.push_str(&format!( "{} {} {}\n", ROCKET, - style(format!("{}", action_name)).bold().underlined().cyan(), - style(format!( - "({})", - pluralize(total_count, "new link", "new links") - )) + conditional_style(&format!("{}", action_name), use_colors) + .bold() + .underlined() + .cyan(), + conditional_style( + &format!("({})", pluralize(total_count, "new link", "new links")), + use_colors + ) .bold() )); @@ -65,16 +85,22 @@ pub fn generate_report( if failed_count > 0 { report.push_str(&format!( " {} Success: {}, {} Failure: {}\n", - style("✅").green(), - style(success_count).bold().green(), - style("❌").red(), - style(failed_count).bold().red() + conditional_style("✅", use_colors).green(), + conditional_style(&success_count.to_string(), use_colors) + .bold() + .green(), + conditional_style("❌", use_colors).red(), + conditional_style(&failed_count.to_string(), use_colors) + .bold() + .red() )); } else { report.push_str(&format!( " {} Success: {}\n", - style("✅").green(), - style(success_count).bold().green() + conditional_style("✅", use_colors).green(), + conditional_style(&success_count.to_string(), use_colors) + .bold() + .green() )); } report.push_str("\n"); @@ -83,14 +109,14 @@ pub fn generate_report( if success_count > 0 { report.push_str(&format!( " {} {}\n", - style("✅").green(), - style("Added:").bold().green() + conditional_style("✅", use_colors).green(), + conditional_style("Added:", use_colors).bold().green() )); for magnet in &processed_magnets.success { report.push_str(&format!( " {} {}\n", LINK, - style(&magnet.title).italic() + conditional_style(&magnet.title, use_colors).italic() )); } report.push_str("\n"); @@ -100,14 +126,14 @@ pub fn generate_report( if failed_count > 0 { report.push_str(&format!( " {} {}\n", - style("❌").red(), - style("Failed:").bold().red() + conditional_style("❌", use_colors).red(), + conditional_style("Failed:", use_colors).bold().red() )); for magnet in &processed_magnets.failed { report.push_str(&format!( " {} {}\n", WARNING, - style(&magnet.title).italic() + conditional_style(&magnet.title, use_colors).italic() )); } report.push_str("\n"); @@ -121,14 +147,7 @@ pub fn generate_report( mod tests { use super::*; use crate::models::Magnet; - use chrono::{DateTime, NaiveDateTime}; - - // Helper function to strip ANSI color codes from a string for testing - fn strip_ansi_codes(s: &str) -> String { - // This regex matches ANSI escape codes - let re = regex::Regex::new(r"\x1B\[[0-9;]*[a-zA-Z]").unwrap(); - re.replace_all(s, "").to_string() - } + use chrono::DateTime; fn create_test_magnet(title: &str) -> Magnet { Magnet { @@ -147,11 +166,10 @@ mod tests { let action_results = HashMap::new(); let total_new_links = 0; - let report = generate_report(&action_results, total_new_links); - let clean_report = strip_ansi_codes(&report); + let report = generate_report(&action_results, total_new_links, false); - assert!(clean_report.contains("Report Summary")); - assert!(clean_report.contains("0 new links added to the database")); + assert!(report.contains("Report Summary")); + assert!(report.contains("0 new links added to the database")); } #[test] @@ -168,7 +186,7 @@ mod tests { action_results.insert("TestAction".to_string(), processed_magnets); let total_new_links = 2; - let report = strip_ansi_codes(&generate_report(&action_results, total_new_links)); + let report = generate_report(&action_results, total_new_links, false); assert!(report.contains("Report Summary")); assert!(report.contains("2 new links added to the database")); @@ -193,7 +211,7 @@ mod tests { action_results.insert("TestAction".to_string(), processed_magnets); let total_new_links = 0; - let report = strip_ansi_codes(&generate_report(&action_results, total_new_links)); + let report = generate_report(&action_results, total_new_links, false); assert!(report.contains("Report Summary")); assert!(report.contains("0 new links added to the database")); @@ -215,7 +233,7 @@ mod tests { action_results.insert("TestAction".to_string(), processed_magnets); let total_new_links = 1; - let report = strip_ansi_codes(&generate_report(&action_results, total_new_links)); + let report = generate_report(&action_results, total_new_links, false); assert!(report.contains("Report Summary")); assert!(report.contains("1 new link added to the database")); @@ -253,7 +271,7 @@ mod tests { action_results.insert("EmptyAction".to_string(), processed_magnets3); let total_new_links = 3; - let report = strip_ansi_codes(&generate_report(&action_results, total_new_links)); + let report = generate_report(&action_results, total_new_links, false); assert!(report.contains("Report Summary")); assert!(report.contains("3 new links added to the database"));