Merge branch 'ntfy' into 'main'

Send ntfy notifications

See merge request kernald/reddit-magnet!14
This commit is contained in:
Marc Plano-Lesay 2025-05-02 02:01:47 +00:00
commit d76a523aeb
10 changed files with 289 additions and 44 deletions

56
Cargo.lock generated
View file

@ -1296,6 +1296,19 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -1490,7 +1503,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.12",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@ -1510,7 +1523,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.12",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@ -1592,6 +1605,7 @@ dependencies = [
"log", "log",
"magnet-url", "magnet-url",
"multimap", "multimap",
"ntfy",
"pretty_env_logger", "pretty_env_logger",
"regex", "regex",
"reqwest 0.12.15", "reqwest 0.12.15",
@ -1613,7 +1627,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
"libredox", "libredox",
"thiserror", "thiserror 2.0.12",
] ]
[[package]] [[package]]
@ -1724,6 +1738,7 @@ dependencies = [
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tokio-socks",
"tower", "tower",
"tower-service", "tower-service",
"url", "url",
@ -2104,13 +2119,33 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ 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]] [[package]]
@ -2237,6 +2272,18 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.15" version = "0.7.15"
@ -2417,6 +2464,7 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]

View file

@ -33,3 +33,4 @@ console = "0.15.8"
reqwest = "0.12.15" reqwest = "0.12.15"
magnet-url = "2.0.0" magnet-url = "2.0.0"
urlencoding = "2.1.3" urlencoding = "2.1.3"
ntfy = "0.7.0"

View file

@ -1,6 +1,7 @@
use crate::actions::bitmagnet::BitmagnetConfig; use crate::actions::bitmagnet::BitmagnetConfig;
use crate::actions::transmission::TransmissionConfig; use crate::actions::transmission::TransmissionConfig;
use crate::args::Args; use crate::args::Args;
use crate::notifications::ntfy::NtfyConfig;
use color_eyre::eyre::{eyre, Result, WrapErr}; use color_eyre::eyre::{eyre, Result, WrapErr};
use directories::ProjectDirs; use directories::ProjectDirs;
use figment::providers::Env; use figment::providers::Env;
@ -31,6 +32,9 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub transmission: Option<TransmissionConfig>, pub transmission: Option<TransmissionConfig>,
#[serde(default)]
pub ntfy: Option<NtfyConfig>,
#[serde(default)] #[serde(default)]
pub sources: HashMap<String, SourceConfig>, pub sources: HashMap<String, SourceConfig>,
} }

View file

@ -5,6 +5,8 @@ use crate::args::Args;
use crate::config::{get_db_path, load_config}; use crate::config::{get_db_path, load_config};
use crate::db::Database; use crate::db::Database;
use crate::magnet::{extract_magnet_links, Magnet}; use crate::magnet::{extract_magnet_links, Magnet};
use crate::notifications::notification::Notification;
use crate::notifications::ntfy::NtfyNotification;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use clap::Parser; use clap::Parser;
use color_eyre::eyre::{eyre, Result, WrapErr}; use color_eyre::eyre::{eyre, Result, WrapErr};
@ -21,6 +23,7 @@ mod config;
mod db; mod db;
mod magnet; mod magnet;
mod models; mod models;
mod notifications;
mod reddit_client; mod reddit_client;
mod report; mod report;
mod schema; mod schema;
@ -172,6 +175,28 @@ async fn main() -> Result<()> {
debug!("No Transmission configuration found"); debug!("No Transmission configuration found");
} }
// Initialize notifications
let mut notifications: Vec<Box<dyn Notification>> = 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 // Process all actions and collect results
let mut action_results = HashMap::new(); 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 &notifications {
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); info!("{}", line);
} }

2
src/notifications/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod notification;
pub mod ntfy;

View file

@ -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<String, ProcessedMagnets>,
total_new_links: usize,
) -> Result<()>;
/// Determine if a notification should be sent based on the results
fn should_notify(
&self,
action_results: &HashMap<String, ProcessedMagnets>,
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
}
}

View file

@ -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<String>,
/// The password for authentication (optional)
#[serde(default)]
pub password: Option<String>,
/// The topic to publish notifications to
pub topic: String,
}

View file

@ -0,0 +1,5 @@
pub mod config;
pub mod notification;
pub use config::NtfyConfig;
pub use notification::NtfyNotification;

View file

@ -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<Async>,
}
impl NtfyNotification {
/// Create a new NtfyNotification instance
pub fn new(config: NtfyConfig) -> Result<Self> {
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<String, ProcessedMagnets>,
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(())
}
}

View file

@ -1,5 +1,4 @@
use crate::actions::action::ProcessedMagnets; use crate::actions::action::ProcessedMagnets;
use color_eyre::eyre::Result;
use console::{style, Emoji}; use console::{style, Emoji};
use std::collections::HashMap; 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 /// Generate a report of processed magnets
pub fn generate_report( pub fn generate_report(
action_results: &HashMap<String, ProcessedMagnets>, action_results: &HashMap<String, ProcessedMagnets>,
total_new_links: usize, total_new_links: usize,
use_colors: bool,
) -> String { ) -> String {
let mut report = String::new(); let mut report = String::new();
@ -28,11 +40,16 @@ pub fn generate_report(
report.push_str(&format!( report.push_str(&format!(
"{} {} {}\n", "{} {} {}\n",
SPARKLES, SPARKLES,
style("Report Summary").bold().underlined(), conditional_style("Report Summary", use_colors)
style(format!( .bold()
"{} added to the database", .underlined(),
pluralize(total_new_links, "new link", "new links") conditional_style(
)) &format!(
"{} added to the database",
pluralize(total_new_links, "new link", "new links")
),
use_colors
)
.bold() .bold()
.green(), .green(),
)); ));
@ -48,11 +65,14 @@ pub fn generate_report(
report.push_str(&format!( report.push_str(&format!(
"{} {} {}\n", "{} {} {}\n",
ROCKET, ROCKET,
style(format!("{}", action_name)).bold().underlined().cyan(), conditional_style(&format!("{}", action_name), use_colors)
style(format!( .bold()
"({})", .underlined()
pluralize(total_count, "new link", "new links") .cyan(),
)) conditional_style(
&format!("({})", pluralize(total_count, "new link", "new links")),
use_colors
)
.bold() .bold()
)); ));
@ -65,16 +85,22 @@ pub fn generate_report(
if failed_count > 0 { if failed_count > 0 {
report.push_str(&format!( report.push_str(&format!(
" {} Success: {}, {} Failure: {}\n", " {} Success: {}, {} Failure: {}\n",
style("").green(), conditional_style("", use_colors).green(),
style(success_count).bold().green(), conditional_style(&success_count.to_string(), use_colors)
style("").red(), .bold()
style(failed_count).bold().red() .green(),
conditional_style("", use_colors).red(),
conditional_style(&failed_count.to_string(), use_colors)
.bold()
.red()
)); ));
} else { } else {
report.push_str(&format!( report.push_str(&format!(
" {} Success: {}\n", " {} Success: {}\n",
style("").green(), conditional_style("", use_colors).green(),
style(success_count).bold().green() conditional_style(&success_count.to_string(), use_colors)
.bold()
.green()
)); ));
} }
report.push_str("\n"); report.push_str("\n");
@ -83,14 +109,14 @@ pub fn generate_report(
if success_count > 0 { if success_count > 0 {
report.push_str(&format!( report.push_str(&format!(
" {} {}\n", " {} {}\n",
style("").green(), conditional_style("", use_colors).green(),
style("Added:").bold().green() conditional_style("Added:", use_colors).bold().green()
)); ));
for magnet in &processed_magnets.success { for magnet in &processed_magnets.success {
report.push_str(&format!( report.push_str(&format!(
" {} {}\n", " {} {}\n",
LINK, LINK,
style(&magnet.title).italic() conditional_style(&magnet.title, use_colors).italic()
)); ));
} }
report.push_str("\n"); report.push_str("\n");
@ -100,14 +126,14 @@ pub fn generate_report(
if failed_count > 0 { if failed_count > 0 {
report.push_str(&format!( report.push_str(&format!(
" {} {}\n", " {} {}\n",
style("").red(), conditional_style("", use_colors).red(),
style("Failed:").bold().red() conditional_style("Failed:", use_colors).bold().red()
)); ));
for magnet in &processed_magnets.failed { for magnet in &processed_magnets.failed {
report.push_str(&format!( report.push_str(&format!(
" {} {}\n", " {} {}\n",
WARNING, WARNING,
style(&magnet.title).italic() conditional_style(&magnet.title, use_colors).italic()
)); ));
} }
report.push_str("\n"); report.push_str("\n");
@ -121,14 +147,7 @@ pub fn generate_report(
mod tests { mod tests {
use super::*; use super::*;
use crate::models::Magnet; use crate::models::Magnet;
use chrono::{DateTime, NaiveDateTime}; use chrono::DateTime;
// 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()
}
fn create_test_magnet(title: &str) -> Magnet { fn create_test_magnet(title: &str) -> Magnet {
Magnet { Magnet {
@ -147,11 +166,10 @@ mod tests {
let action_results = HashMap::new(); let action_results = HashMap::new();
let total_new_links = 0; let total_new_links = 0;
let report = generate_report(&action_results, total_new_links); let report = generate_report(&action_results, total_new_links, false);
let clean_report = strip_ansi_codes(&report);
assert!(clean_report.contains("Report Summary")); assert!(report.contains("Report Summary"));
assert!(clean_report.contains("0 new links added to the database")); assert!(report.contains("0 new links added to the database"));
} }
#[test] #[test]
@ -168,7 +186,7 @@ mod tests {
action_results.insert("TestAction".to_string(), processed_magnets); action_results.insert("TestAction".to_string(), processed_magnets);
let total_new_links = 2; 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("Report Summary"));
assert!(report.contains("2 new links added to the database")); assert!(report.contains("2 new links added to the database"));
@ -193,7 +211,7 @@ mod tests {
action_results.insert("TestAction".to_string(), processed_magnets); action_results.insert("TestAction".to_string(), processed_magnets);
let total_new_links = 0; 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("Report Summary"));
assert!(report.contains("0 new links added to the database")); assert!(report.contains("0 new links added to the database"));
@ -215,7 +233,7 @@ mod tests {
action_results.insert("TestAction".to_string(), processed_magnets); action_results.insert("TestAction".to_string(), processed_magnets);
let total_new_links = 1; 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("Report Summary"));
assert!(report.contains("1 new link added to the database")); assert!(report.contains("1 new link added to the database"));
@ -253,7 +271,7 @@ mod tests {
action_results.insert("EmptyAction".to_string(), processed_magnets3); action_results.insert("EmptyAction".to_string(), processed_magnets3);
let total_new_links = 3; 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("Report Summary"));
assert!(report.contains("3 new links added to the database")); assert!(report.contains("3 new links added to the database"));