use crate::actions::transmission::{TransmissionAction, TransmissionConfig}; use crate::db::Database; use crate::magnet::{extract_magnet_links, Magnet}; use chrono::{DateTime, Utc}; use clap::Parser; use clap_verbosity_flag::{InfoLevel, Verbosity}; use color_eyre::eyre::{eyre, Result, WrapErr}; use directories::ProjectDirs; use figment::providers::Env; use figment::{ providers::{Format, Toml}, Figment, }; use figment_file_provider_adapter::FileAdapter; use log::{debug, info, warn}; use multimap::MultiMap; use reddit_client::RedditClient; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs::create_dir_all; use std::path::{Path, PathBuf}; mod actions; mod db; mod magnet; mod models; mod reddit_client; mod schema; #[derive(Debug, Serialize, Deserialize)] struct SourceConfig { username: String, title_filter: Option, } #[derive(Debug, Serialize, Deserialize)] struct Config { #[serde(default)] transmission: Option, #[serde(default)] sources: HashMap, } #[derive(Debug)] struct PostInfo { title: String, submitter: String, magnet_links: Vec, subreddit: String, timestamp: DateTime, } #[derive(Parser, Debug)] #[command(author, version, about = "Display recent posts from a Reddit user")] struct Args { /// Path to the configuration file #[arg(short, long)] config: Option, /// Path to the database file #[arg(short, long)] db: Option, #[command(flatten)] verbose: Verbosity, } /// 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, } } /// Prints the posts with their magnet links fn print_posts(posts: &[PostInfo], username: &str, title_filter: Option) -> usize { println!("Magnet links from u/{}:", username); if let Some(pattern) = title_filter { println!("Filtering titles by pattern: {}", pattern); } println!("----------------------------"); let mut post_count = 0; for post in posts { // Only display posts with magnet links if !post.magnet_links.is_empty() { post_count += 1; println!( "{}. [r/{}] {} (Posted: {})", post_count, post.subreddit, post.title, post.timestamp.format("%Y-%m-%d %H:%M:%S") ); for (i, link) in post.magnet_links.iter().enumerate() { println!(" Link {}: {}", i + 1, link); } println!(); } } if post_count == 0 { println!("No posts with magnet links found."); } post_count } #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; let args = Args::parse(); pretty_env_logger::formatted_timed_builder() .filter_level(args.verbose.log_level_filter()) .init(); // Initialize database let db_path = match args.db { Some(path) => PathBuf::from(path), None => ProjectDirs::from("fr", "enoent", "reddit-magnet") .map(|p| p.data_dir().join("reddit-magnet.db")) .ok_or_else(|| eyre!("Could not determine data directory"))?, }; // Create parent directory if it doesn't exist if let Some(parent) = db_path.parent() { create_dir_all(parent) .wrap_err_with(|| format!("Failed to create directory: {:?}", parent))?; } let mut db = Database::new(&db_path) .wrap_err_with(|| format!("Failed to initialize database at {:?}", db_path))?; let mut conf_extractor = Figment::new(); let config_file_path: Option = match args.config { Some(path) => Some(Path::new(&path).to_path_buf()), None => ProjectDirs::from("fr", "enoent", "reddit-magnet") .map(|p| p.config_dir().join("config.toml")), }; match config_file_path { Some(path) => { if path.exists() { debug!("Reading configuration from {:?}", path); conf_extractor = conf_extractor.merge(FileAdapter::wrap(Toml::file_exact(path))); } else { debug!("Configuration file doesn't exist at {:?}", path); } } None => { debug!("No configuration file specified, using default configuration"); } } let conf: Config = conf_extractor .merge(FileAdapter::wrap(Env::prefixed("REDDIT_MAGNET_"))) .extract() .wrap_err_with(|| "Invalid configuration or insufficient command line arguments")?; 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).await?; user_posts.insert_many(username, submissions); } // Process sources and store magnet links for (source_name, source_config) in conf.sources { println!("\nProcessing 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) { let mut filtered_posts = Vec::new(); 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, }; // Store the post info in the database if let Err(e) = db.store_magnets(&post_info) { warn!("Failed to store post info in database: {}", e); } filtered_posts.push(post_info); } } print_posts(&filtered_posts, &username, title_filter); } } // Process magnet links with Transmission if enabled if let Some(transmission_config) = conf.transmission { if transmission_config.enable { info!("Processing magnet links with Transmission"); match TransmissionAction::new(&transmission_config, db).await { Ok(mut transmission_action) => { match transmission_action.process_unprocessed_magnets().await { Ok(count) => { info!( "Successfully processed {} magnet links with Transmission", count ); } Err(e) => { warn!("Failed to process magnet links with Transmission: {}", e); } } } Err(e) => { warn!("Failed to initialize Transmission action: {}", e); } } } else { debug!("Transmission action is disabled"); } } else { debug!("No Transmission configuration found"); } Ok(()) }