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",
]
[[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]]

View file

@ -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"

View file

@ -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<TransmissionConfig>,
#[serde(default)]
pub ntfy: Option<NtfyConfig>,
#[serde(default)]
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::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<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
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);
}

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 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<String, ProcessedMagnets>,
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"));