From 3275f4890d300c01ee664b230f30595bed5e18c3 Mon Sep 17 00:00:00 2001 From: Marc Plano-Lesay Date: Fri, 2 May 2025 14:23:12 +1000 Subject: [PATCH] Clean things up a bit --- src/actions/action.rs | 6 +- src/actions/bitmagnet/action.rs | 13 +- src/actions/bitmagnet/client.rs | 32 ---- src/actions/bitmagnet/config.rs | 9 +- src/actions/bitmagnet/mod.rs | 1 - src/actions/factory.rs | 123 +++++++++++++ src/actions/mod.rs | 1 + src/actions/transmission/action.rs | 13 +- src/actions/transmission/config.rs | 9 +- src/app.rs | 244 +++++++++++++++++++++++++ src/db.rs | 1 + src/main.rs | 220 ++-------------------- src/models.rs | 3 + src/notifications/factory.rs | 123 +++++++++++++ src/notifications/mod.rs | 1 + src/notifications/notification.rs | 6 +- src/notifications/ntfy/config.rs | 9 +- src/notifications/ntfy/notification.rs | 7 +- 18 files changed, 572 insertions(+), 249 deletions(-) create mode 100644 src/actions/factory.rs create mode 100644 src/app.rs create mode 100644 src/notifications/factory.rs diff --git a/src/actions/action.rs b/src/actions/action.rs index 9f2c0ae..f8e381b 100644 --- a/src/actions/action.rs +++ b/src/actions/action.rs @@ -12,7 +12,11 @@ pub struct ProcessedMagnets { #[async_trait] pub trait Action { /// Return the name of the action - fn name(&self) -> &str; + fn name() -> &'static str + where + Self: Sized; + + fn get_name(&self) -> &'static str; /// Process all unprocessed magnet links and return the list of processed magnets async fn process_unprocessed_magnets( diff --git a/src/actions/bitmagnet/action.rs b/src/actions/bitmagnet/action.rs index 90a976c..50b1ce0 100644 --- a/src/actions/bitmagnet/action.rs +++ b/src/actions/bitmagnet/action.rs @@ -11,7 +11,7 @@ pub struct BitmagnetAction { } impl BitmagnetAction { - pub async fn new(config: &BitmagnetConfig) -> Result { + pub fn new(config: &BitmagnetConfig) -> Result { let client = BitmagnetClient::new(config)?; Ok(BitmagnetAction { client }) @@ -21,10 +21,14 @@ impl BitmagnetAction { #[async_trait::async_trait] impl Action for BitmagnetAction { /// Return the name of the action - fn name(&self) -> &str { + fn name() -> &'static str { "Bitmagnet" } + fn get_name(&self) -> &'static str { + Self::name() + } + /// Process all unprocessed magnet links and return the list of processed magnets async fn process_unprocessed_magnets(&mut self, db: &mut Database) -> Result { let unprocessed_magnets = @@ -45,7 +49,8 @@ impl Action for BitmagnetAction { { Ok(_) => { debug!( - "Successfully submitted magnet link to Bitmagnet: {}", + "Successfully submitted magnet link to {}: {}", + Self::name(), magnet.title ); debug!("Magnet link: {}", magnet.link); @@ -53,7 +58,7 @@ impl Action for BitmagnetAction { processed_magnets.push(magnet); } Err(e) => { - warn!("Failed to submit magnet link to Bitmagnet: {}", e); + warn!("Failed to submit magnet link to {}: {}", Self::name(), e); failed_magnets.push(magnet); } } diff --git a/src/actions/bitmagnet/client.rs b/src/actions/bitmagnet/client.rs index 59aadf8..0625145 100644 --- a/src/actions/bitmagnet/client.rs +++ b/src/actions/bitmagnet/client.rs @@ -1,7 +1,6 @@ use crate::actions::bitmagnet::config::BitmagnetConfig; use chrono::{DateTime, Utc}; use color_eyre::eyre::{eyre, Result, WrapErr}; -use log::{info, warn}; use magnet_url::Magnet; use reqwest::Client; use serde_json::json; @@ -95,35 +94,4 @@ impl BitmagnetClient { Ok(()) } - - /// Extract the info hash from a magnet link - fn extract_info_hash(magnet: &str) -> Result { - // Magnet links typically have the format: magnet:?xt=urn:btih:&dn=&... - let parts: Vec<&str> = magnet.split('&').collect(); - - for part in parts { - if part.starts_with("magnet:?xt=urn:btih:") { - return Ok(part[20..].to_string()); - } - if part.starts_with("xt=urn:btih:") { - return Ok(part[12..].to_string()); - } - } - - Err(eyre!("Info hash not found in magnet link")) - } - - /// Extract the name from a magnet link - fn extract_name(magnet: &str) -> Option { - // Magnet links typically have the format: magnet:?xt=urn:btih:&dn=&... - let parts: Vec<&str> = magnet.split('&').collect(); - - for part in parts { - if part.starts_with("dn=") { - return Some(part[3..].to_string()); - } - } - - None - } } diff --git a/src/actions/bitmagnet/config.rs b/src/actions/bitmagnet/config.rs index 99634f1..66a6659 100644 --- a/src/actions/bitmagnet/config.rs +++ b/src/actions/bitmagnet/config.rs @@ -1,8 +1,15 @@ +use crate::app::Enableable; use serde::{Deserialize, Serialize}; /// Configuration for the Bitmagnet action -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BitmagnetConfig { pub enable: bool, pub host: String, } + +impl Enableable for BitmagnetConfig { + fn is_enabled(&self) -> bool { + self.enable + } +} diff --git a/src/actions/bitmagnet/mod.rs b/src/actions/bitmagnet/mod.rs index 4a8ea7a..f586b91 100644 --- a/src/actions/bitmagnet/mod.rs +++ b/src/actions/bitmagnet/mod.rs @@ -3,5 +3,4 @@ mod client; mod config; pub use action::BitmagnetAction; -pub use client::BitmagnetClient; pub use config::BitmagnetConfig; diff --git a/src/actions/factory.rs b/src/actions/factory.rs new file mode 100644 index 0000000..41360ce --- /dev/null +++ b/src/actions/factory.rs @@ -0,0 +1,123 @@ +use crate::actions::action::Action; +use crate::app::Enableable; +use color_eyre::eyre::Result; +use log::{debug, info, warn}; + +/// Initialize an action from a configuration +pub fn init_action(config_opt: &Option, creator: F) -> Result>> +where + C: Enableable + Clone, + A: Action + 'static, + F: FnOnce(&C) -> Result, +{ + if let Some(config) = config_opt { + if config.is_enabled() { + info!("Initializing {}", A::name()); + match creator(config) { + Ok(action) => { + return Ok(Some(Box::new(action))); + } + Err(e) => { + warn!("Failed to initialize {}: {}", A::name(), e); + } + } + } else { + debug!("{} is disabled", A::name()); + } + } else { + debug!("No {} configuration found", A::name()); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::action::ProcessedMagnets; + use crate::db::Database; + use crate::models::Magnet; + use async_trait::async_trait; + use color_eyre::eyre::{eyre, Result}; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[derive(Clone)] + struct FakeConfig { + enabled: bool, + } + + impl Enableable for FakeConfig { + fn is_enabled(&self) -> bool { + self.enabled + } + } + + struct FakeAction {} + + #[async_trait] + impl Action for FakeAction { + fn name() -> &'static str { + "FakeAction" + } + + fn get_name(&self) -> &'static str { + "FakeAction" + } + + async fn process_unprocessed_magnets( + &mut self, + _db: &mut Database, + ) -> Result { + Ok(ProcessedMagnets { + success: Vec::::new(), + failed: Vec::::new(), + }) + } + } + + #[test] + fn test_init_action_no_config() { + let config: Option = None; + let result = init_action::<_, FakeAction, _>(&config, |_| { + panic!("Creator should not be called when config is None"); + }) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_action_disabled_config() { + let config = Some(FakeConfig { enabled: false }); + let result = init_action::<_, FakeAction, _>(&config, |_| { + panic!("Creator should not be called when config is disabled"); + }) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_action_creator_fails() { + let config = Some(FakeConfig { enabled: true }); + let result = + init_action::<_, FakeAction, _>(&config, |_| Err(eyre!("Creator failed"))).unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_action_success() { + let config = Some(FakeConfig { enabled: true }); + let creator_called = AtomicBool::new(false); + + let result = init_action::<_, FakeAction, _>(&config, |_| { + creator_called.store(true, Ordering::SeqCst); + Ok(FakeAction {}) + }) + .unwrap(); + + assert!(creator_called.load(Ordering::SeqCst)); + assert!(result.is_some()); + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 3674fa3..ef69f6b 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -1,3 +1,4 @@ pub mod action; pub mod bitmagnet; +pub mod factory; pub mod transmission; diff --git a/src/actions/transmission/action.rs b/src/actions/transmission/action.rs index fd3df00..4448eaf 100644 --- a/src/actions/transmission/action.rs +++ b/src/actions/transmission/action.rs @@ -11,7 +11,7 @@ pub struct TransmissionAction { } impl TransmissionAction { - pub async fn new(config: &TransmissionConfig) -> Result { + pub fn new(config: &TransmissionConfig) -> Result { let client = TransmissionClient::new(config)?; Ok(TransmissionAction { client }) @@ -21,10 +21,14 @@ impl TransmissionAction { #[async_trait::async_trait] impl Action for TransmissionAction { /// Return the name of the action - fn name(&self) -> &str { + fn name() -> &'static str { "Transmission" } + fn get_name(&self) -> &'static str { + Self::name() + } + /// Process all unprocessed magnet links and return the list of processed magnets async fn process_unprocessed_magnets(&mut self, db: &mut Database) -> Result { let unprocessed_magnets = @@ -37,7 +41,8 @@ impl Action for TransmissionAction { match self.client.submit_magnet(&magnet.link).await { Ok(_) => { debug!( - "Successfully submitted magnet link to Transmission: {}", + "Successfully submitted magnet link to {}: {}", + Self::name(), magnet.title ); debug!("Magnet link: {}", magnet.link); @@ -45,7 +50,7 @@ impl Action for TransmissionAction { processed_magnets.push(magnet); } Err(e) => { - warn!("Failed to submit magnet link to Transmission: {}", e); + warn!("Failed to submit magnet link to {}: {}", Self::name(), e); failed_magnets.push(magnet); } } diff --git a/src/actions/transmission/config.rs b/src/actions/transmission/config.rs index ffd7f2b..bfdfd85 100644 --- a/src/actions/transmission/config.rs +++ b/src/actions/transmission/config.rs @@ -1,7 +1,8 @@ +use crate::app::Enableable; use serde::{Deserialize, Serialize}; /// Configuration for the Transmission action -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransmissionConfig { pub enable: bool, pub host: String, @@ -10,3 +11,9 @@ pub struct TransmissionConfig { pub port: u16, pub download_dir: String, } + +impl Enableable for TransmissionConfig { + fn is_enabled(&self) -> bool { + self.enable + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..06027fb --- /dev/null +++ b/src/app.rs @@ -0,0 +1,244 @@ +use crate::actions::action::{Action, ProcessedMagnets}; +use crate::actions::bitmagnet::BitmagnetAction; +use crate::actions::factory::init_action; +use crate::actions::transmission::TransmissionAction; +use crate::config::Config; +use crate::db::Database; +use crate::notifications::factory::init_notification; +use crate::notifications::notification::Notification; +use crate::notifications::ntfy::NtfyNotification; +use crate::reddit_client::{RedditClient, RedditPost}; +use crate::report; +use color_eyre::eyre::{Result, WrapErr}; +use log::{debug, info, warn}; +use multimap::MultiMap; +use regex::Regex; +use std::collections::{HashMap, HashSet}; + +/// Trait for configurations that can be enabled or disabled +pub trait Enableable { + /// Returns whether the configuration is enabled + fn is_enabled(&self) -> bool; +} + +/// Application state and behavior +pub struct App { + db: Database, + config: Config, + actions: Vec>, + notifications: Vec>, + reddit_client: RedditClient, +} + +impl App { + /// Create a new App instance + pub fn new(db: Database, config: Config) -> Self { + Self { + db, + config, + actions: Vec::new(), + notifications: Vec::new(), + reddit_client: RedditClient::new(), + } + } + + /// Initialize actions based on configuration + pub fn init_actions(&mut self) -> Result<()> { + if let Some(action) = init_action(&mut &self.config.bitmagnet, BitmagnetAction::new)? { + self.actions.push(action); + } + if let Some(action) = init_action(&mut &self.config.transmission, TransmissionAction::new)? + { + self.actions.push(action); + } + + Ok(()) + } + + /// Initialize notifications based on configuration + pub fn init_notifications(&mut self) -> Result<()> { + if let Some(notification) = init_notification(&self.config.ntfy, NtfyNotification::new)? { + self.notifications.push(notification); + } + + Ok(()) + } + + /// Fetch posts from Reddit + pub async fn fetch_posts(&self, post_count: u32) -> Result> { + let mut unique_usernames = HashSet::new(); + for (_, source_config) in &self.config.sources { + unique_usernames.insert(source_config.username.clone()); + } + + let mut user_posts = MultiMap::new(); + for username in unique_usernames { + info!("Fetching posts from user [{}]", username); + let submissions = self + .reddit_client + .fetch_user_submissions(&username, post_count) + .await?; + user_posts.insert_many(username, submissions); + } + + Ok(user_posts) + } + + /// Process sources and extract magnet links + pub async fn process_sources( + &mut self, + user_posts: &MultiMap, + ) -> Result { + let mut total_new_links = 0; + + for (source_name, source_config) in &self.config.sources { + info!("Processing source [{}]", source_name); + + let username = source_config.username.clone(); + let title_filter = match &source_config.title_filter { + Some(filter) => Some( + Regex::new(filter.as_str()) + .context(format!("Invalid regex pattern: {}", filter))?, + ), + None => None, + }; + + if let Some(submissions) = user_posts.get_vec(&username) { + for post in submissions + .iter() + .filter(|s| filter_post(&s.title, title_filter.clone())) + { + let title = &post.title; + let body = &post.body; + let subreddit = &post.subreddit; + + let magnet_links = crate::magnet::extract_magnet_links(body); + if !magnet_links.is_empty() { + let post_info = crate::PostInfo { + title: title.to_string(), + submitter: username.clone(), + subreddit: subreddit.to_string(), + magnet_links, + timestamp: post.created, + imdb_id: source_config.imdb_id.clone(), + }; + + // Store the post info in the database + match self.db.store_magnets(&post_info) { + Ok(count) => { + total_new_links += count; + } + Err(e) => { + warn!("Failed to store post info in database: {}", e); + } + } + } + } + } + } + + Ok(total_new_links) + } + + /// Process magnet links with actions + pub async fn process_actions(&mut self) -> Result> { + let mut action_results = HashMap::new(); + + for action in &mut self.actions { + debug!("Processing magnet links with {}", action.get_name()); + + match action.process_unprocessed_magnets(&mut self.db).await { + Ok(processed_magnets) => { + let count = processed_magnets.success.len(); + debug!( + "Successfully processed {} magnet links with {}", + count, + action.get_name() + ); + action_results.insert(action.get_name().to_string(), processed_magnets); + } + Err(e) => { + warn!( + "Failed to process magnet links with {}: {}", + action.get_name(), + e + ); + } + } + } + + Ok(action_results) + } + + /// Send notifications + pub async fn send_notifications( + &self, + action_results: &HashMap, + total_new_links: usize, + ) -> Result<()> { + for notification in &self.notifications { + match notification + .send_notification(action_results, total_new_links) + .await + { + Ok(_) => { + debug!( + "Successfully sent notification to {}", + notification.get_name() + ); + } + Err(e) => { + warn!( + "Failed to send notification to {}: {}", + notification.get_name(), + e + ); + } + } + } + + Ok(()) + } + + /// Generate and display report + pub fn generate_report( + &self, + action_results: &HashMap, + total_new_links: usize, + ) { + for line in report::generate_report(action_results, total_new_links, true).lines() { + info!("{}", line); + } + } + + pub async fn run(&mut self, post_count: u32) -> Result<()> { + self.init_actions()?; + self.init_notifications()?; + + // Fetch posts from Reddit + let user_posts = self.fetch_posts(post_count).await?; + + // Process sources and extract magnet links + let total_new_links = self.process_sources(&user_posts).await?; + + // Process magnet links with actions + let action_results = self.process_actions().await?; + + // Send notifications + self.send_notifications(&action_results, total_new_links) + .await?; + + // Generate and display report + self.generate_report(&action_results, total_new_links); + + Ok(()) + } +} + +/// Filters posts based on a title filter pattern +fn filter_post(title: &str, title_filter: Option) -> bool { + match title_filter { + Some(pattern) => pattern.is_match(title), + None => true, + } +} diff --git a/src/db.rs b/src/db.rs index 6c0a3f0..56cd851 100644 --- a/src/db.rs +++ b/src/db.rs @@ -95,6 +95,7 @@ impl Database { Ok(Database { conn }) } + #[allow(dead_code)] // Used in tests, might come in handy pub fn get_all_magnets(&mut self) -> Result> { let results = magnets::table .select(Magnet::as_select()) diff --git a/src/main.rs b/src/main.rs index 1b550d0..87dbfb5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,14 @@ -use crate::actions::action::Action; -use crate::actions::bitmagnet::BitmagnetAction; -use crate::actions::transmission::TransmissionAction; +use crate::app::App; 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}; -use log::{debug, info, warn}; -use multimap::MultiMap; -use reddit_client::RedditClient; -use regex::Regex; -use std::collections::{HashMap, HashSet}; +use color_eyre::eyre::{Result, WrapErr}; use std::fs::create_dir_all; mod actions; +mod app; mod args; mod config; mod db; @@ -28,30 +19,26 @@ mod reddit_client; mod report; mod schema; +/// Post information with magnet links #[derive(Debug)] -struct PostInfo { - title: String, - submitter: String, - magnet_links: Vec, - subreddit: String, - timestamp: DateTime, - imdb_id: Option, -} - -/// Filters posts based on a title filter pattern -fn filter_posts(title: &str, title_filter: Option) -> bool { - match title_filter { - Some(pattern) => pattern.is_match(title), - None => true, - } +pub struct PostInfo { + pub title: String, + pub submitter: String, + pub magnet_links: Vec, + pub subreddit: String, + pub timestamp: DateTime, + pub imdb_id: Option, } #[tokio::main] async fn main() -> Result<()> { + // Initialize error handling color_eyre::install()?; + // Parse command-line arguments let args = Args::parse(); + // Initialize logging pretty_env_logger::formatted_timed_builder() .filter_level(args.verbose.log_level_filter()) .init(); @@ -65,185 +52,14 @@ async fn main() -> Result<()> { .wrap_err_with(|| format!("Failed to create directory: {:?}", parent))?; } - let mut db = Database::new(&db_path) + let db = Database::new(&db_path) .wrap_err_with(|| format!("Failed to initialize database at {:?}", db_path))?; + // Load configuration let conf = load_config(&args)?; - if conf.sources.is_empty() { - return Err(eyre!("No sources found in configuration. Please add at least one source to your configuration file.").into()); - } - - let mut unique_usernames = HashSet::new(); - for (_, source_config) in &conf.sources { - unique_usernames.insert(source_config.username.clone()); - } - - let reddit_client = RedditClient::new(); - let mut user_posts = MultiMap::new(); - for username in unique_usernames { - let submissions = reddit_client - .fetch_user_submissions(&username, args.post_count) - .await?; - user_posts.insert_many(username, submissions); - } - - // Process sources and store magnet links - let mut total_new_links = 0; - for (source_name, source_config) in conf.sources { - info!("Processing source [{}]", source_name); - - let username = source_config.username.clone(); - let title_filter = match source_config.title_filter { - Some(filter) => Some( - Regex::new(filter.as_str()) - .context(format!("Invalid regex pattern: {}", filter))?, - ), - None => None, - }; - - if let Some(submissions) = user_posts.get_vec(&username) { - for post in submissions - .iter() - .filter(|s| filter_posts(&*s.title, title_filter.clone())) - { - let title = &post.title; - let body = &post.body; - let subreddit = &post.subreddit; - - let magnet_links = extract_magnet_links(body); - if !magnet_links.is_empty() { - let post_info = PostInfo { - title: title.to_string(), - submitter: username.clone(), - subreddit: subreddit.to_string(), - magnet_links, - timestamp: post.created, - imdb_id: source_config.imdb_id.clone(), - }; - - // Store the post info in the database - match db.store_magnets(&post_info) { - Ok(count) => { - total_new_links += count; - } - Err(e) => { - warn!("Failed to store post info in database: {}", e); - } - } - } - } - } - } - - // Initialize actions - let mut actions: Vec> = Vec::new(); - - // Add Bitmagnet action if enabled - if let Some(bitmagnet_config) = conf.bitmagnet { - if bitmagnet_config.enable { - info!("Initializing Bitmagnet action"); - match BitmagnetAction::new(&bitmagnet_config).await { - Ok(bitmagnet_action) => { - actions.push(Box::new(bitmagnet_action)); - } - Err(e) => { - warn!("Failed to initialize Bitmagnet action: {}", e); - } - } - } else { - debug!("Bitmagnet action is disabled"); - } - } else { - debug!("No Bitmagnet configuration found"); - } - - // Add Transmission action if enabled - if let Some(transmission_config) = conf.transmission { - if transmission_config.enable { - info!("Initializing Transmission action"); - match TransmissionAction::new(&transmission_config).await { - Ok(transmission_action) => { - actions.push(Box::new(transmission_action)); - } - Err(e) => { - warn!("Failed to initialize Transmission action: {}", e); - } - } - } else { - debug!("Transmission action is disabled"); - } - } else { - 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(); - - for action in &mut actions { - let action_name = action.name().to_string(); - debug!("Processing magnet links with {} action", action_name); - - match action.process_unprocessed_magnets(&mut db).await { - Ok(processed_magnets) => { - let count = processed_magnets.success.len(); - debug!( - "Successfully processed {} magnet links with {}", - count, action_name - ); - action_results.insert(action_name, processed_magnets); - } - Err(e) => { - warn!("Failed to process magnet links with {}: {}", action_name, e); - } - } - } - - // 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); - } + // Create and run the application + App::new(db, conf).run(args.post_count).await?; Ok(()) } diff --git a/src/models.rs b/src/models.rs index c7aca1c..bb219eb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2,6 +2,7 @@ use crate::schema::{bitmagnet_processed, magnets, transmission_processed}; use chrono::NaiveDateTime; use diesel::prelude::*; +#[allow(dead_code)] #[derive(Queryable, Selectable)] #[diesel(table_name = magnets)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -27,6 +28,7 @@ pub struct NewMagnet<'a> { pub imdb_id: Option<&'a str>, } +#[allow(dead_code)] #[derive(Queryable, Selectable)] #[diesel(table_name = bitmagnet_processed)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -44,6 +46,7 @@ pub struct NewBitmagnetProcessed<'a> { pub processed_at: &'a NaiveDateTime, } +#[allow(dead_code)] #[derive(Queryable, Selectable)] #[diesel(table_name = transmission_processed)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] diff --git a/src/notifications/factory.rs b/src/notifications/factory.rs new file mode 100644 index 0000000..6b7cc50 --- /dev/null +++ b/src/notifications/factory.rs @@ -0,0 +1,123 @@ +use crate::app::Enableable; +use crate::notifications::notification::Notification; +use color_eyre::eyre::Result; +use log::{debug, info, warn}; + +/// Initialize a notification from a configuration +pub fn init_notification( + config_opt: &Option, + creator: F, +) -> Result>> +where + C: Enableable + Clone, + N: Notification + 'static, + F: FnOnce(C) -> Result, +{ + if let Some(config) = config_opt { + if config.is_enabled() { + info!("Initializing {}", N::name()); + match creator(config.clone()) { + Ok(notification) => { + return Ok(Some(Box::new(notification))); + } + Err(e) => { + warn!("Failed to initialize {}: {}", N::name(), e); + } + } + } else { + debug!("{} is disabled", N::name()); + } + } else { + debug!("No {} configuration found", N::name()); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use color_eyre::eyre::{eyre, Result}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[derive(Clone)] + struct FakeConfig { + enabled: bool, + } + + impl Enableable for FakeConfig { + fn is_enabled(&self) -> bool { + self.enabled + } + } + + struct FakeNotification {} + + #[async_trait] + impl Notification for FakeNotification { + fn name() -> &'static str { + "FakeNotification" + } + + fn get_name(&self) -> &'static str { + "FakeNotification" + } + + async fn send_notification( + &self, + _action_results: &HashMap, + _total_new_links: usize, + ) -> Result<()> { + Ok(()) + } + } + + #[test] + fn test_init_notification_no_config() { + let config: Option = None; + let result = init_notification::<_, FakeNotification, _>(&config, |_| { + panic!("Creator should not be called when config is None"); + }) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_notification_disabled_config() { + let config = Some(FakeConfig { enabled: false }); + let result = init_notification::<_, FakeNotification, _>(&config, |_| { + panic!("Creator should not be called when config is disabled"); + }) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_notification_creator_fails() { + let config = Some(FakeConfig { enabled: true }); + let result = + init_notification::<_, FakeNotification, _>(&config, |_| Err(eyre!("Creator failed"))) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn test_init_notification_success() { + let config = Some(FakeConfig { enabled: true }); + let creator_called = AtomicBool::new(false); + + let result = init_notification::<_, FakeNotification, _>(&config, |_| { + creator_called.store(true, Ordering::SeqCst); + Ok(FakeNotification {}) + }) + .unwrap(); + + assert!(creator_called.load(Ordering::SeqCst)); + assert!(result.is_some()); + } +} diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 9b278a1..1c65714 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,2 +1,3 @@ +pub mod factory; pub mod notification; pub mod ntfy; diff --git a/src/notifications/notification.rs b/src/notifications/notification.rs index 71be9cb..cee30d4 100644 --- a/src/notifications/notification.rs +++ b/src/notifications/notification.rs @@ -7,7 +7,11 @@ use std::collections::HashMap; #[async_trait] pub trait Notification { /// Return the name of the notification service - fn name(&self) -> &str; + fn name() -> &'static str + where + Self: Sized; + + fn get_name(&self) -> &'static str; /// Send a notification about the processing results async fn send_notification( diff --git a/src/notifications/ntfy/config.rs b/src/notifications/ntfy/config.rs index 99bb9c3..b2bd6f5 100644 --- a/src/notifications/ntfy/config.rs +++ b/src/notifications/ntfy/config.rs @@ -1,7 +1,8 @@ +use crate::app::Enableable; use serde::{Deserialize, Serialize}; /// Configuration for the ntfy notification service -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NtfyConfig { /// Whether to enable the ntfy notification service #[serde(default)] @@ -21,3 +22,9 @@ pub struct NtfyConfig { /// The topic to publish notifications to pub topic: String, } + +impl Enableable for NtfyConfig { + fn is_enabled(&self) -> bool { + self.enable + } +} diff --git a/src/notifications/ntfy/notification.rs b/src/notifications/ntfy/notification.rs index 54b306b..99800e5 100644 --- a/src/notifications/ntfy/notification.rs +++ b/src/notifications/ntfy/notification.rs @@ -32,10 +32,15 @@ impl NtfyNotification { #[async_trait] impl Notification for NtfyNotification { - fn name(&self) -> &str { + /// Return the name of the notification service + fn name() -> &'static str { "Ntfy" } + fn get_name(&self) -> &'static str { + Self::name() + } + async fn send_notification( &self, action_results: &HashMap,