Merge branch 'cleanup' into 'main'
Clean things up a bit See merge request kernald/reddit-magnet!16
This commit is contained in:
commit
7c9810d0bf
18 changed files with 572 additions and 249 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ pub struct BitmagnetAction {
|
|||
}
|
||||
|
||||
impl BitmagnetAction {
|
||||
pub async fn new(config: &BitmagnetConfig) -> Result<Self> {
|
||||
pub fn new(config: &BitmagnetConfig) -> Result<Self> {
|
||||
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<ProcessedMagnets> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
// Magnet links typically have the format: magnet:?xt=urn:btih:<info_hash>&dn=<name>&...
|
||||
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<String> {
|
||||
// Magnet links typically have the format: magnet:?xt=urn:btih:<info_hash>&dn=<name>&...
|
||||
let parts: Vec<&str> = magnet.split('&').collect();
|
||||
|
||||
for part in parts {
|
||||
if part.starts_with("dn=") {
|
||||
return Some(part[3..].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,4 @@ mod client;
|
|||
mod config;
|
||||
|
||||
pub use action::BitmagnetAction;
|
||||
pub use client::BitmagnetClient;
|
||||
pub use config::BitmagnetConfig;
|
||||
|
|
|
|||
123
src/actions/factory.rs
Normal file
123
src/actions/factory.rs
Normal file
|
|
@ -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<C, A, F>(config_opt: &Option<C>, creator: F) -> Result<Option<Box<dyn Action>>>
|
||||
where
|
||||
C: Enableable + Clone,
|
||||
A: Action + 'static,
|
||||
F: FnOnce(&C) -> Result<A>,
|
||||
{
|
||||
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<ProcessedMagnets> {
|
||||
Ok(ProcessedMagnets {
|
||||
success: Vec::<Magnet>::new(),
|
||||
failed: Vec::<Magnet>::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_action_no_config() {
|
||||
let config: Option<FakeConfig> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod action;
|
||||
pub mod bitmagnet;
|
||||
pub mod factory;
|
||||
pub mod transmission;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ pub struct TransmissionAction {
|
|||
}
|
||||
|
||||
impl TransmissionAction {
|
||||
pub async fn new(config: &TransmissionConfig) -> Result<Self> {
|
||||
pub fn new(config: &TransmissionConfig) -> Result<Self> {
|
||||
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<ProcessedMagnets> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
244
src/app.rs
Normal file
244
src/app.rs
Normal file
|
|
@ -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<Box<dyn Action>>,
|
||||
notifications: Vec<Box<dyn Notification>>,
|
||||
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<MultiMap<String, RedditPost>> {
|
||||
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<String, RedditPost>,
|
||||
) -> Result<usize> {
|
||||
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<HashMap<String, ProcessedMagnets>> {
|
||||
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<String, ProcessedMagnets>,
|
||||
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<String, ProcessedMagnets>,
|
||||
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<Regex>) -> bool {
|
||||
match title_filter {
|
||||
Some(pattern) => pattern.is_match(title),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<Magnet>> {
|
||||
let results = magnets::table
|
||||
.select(Magnet::as_select())
|
||||
|
|
|
|||
220
src/main.rs
220
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<Magnet>,
|
||||
subreddit: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Filters posts based on a title filter pattern
|
||||
fn filter_posts(title: &str, title_filter: Option<Regex>) -> 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<magnet::Magnet>,
|
||||
pub subreddit: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Box<dyn Action>> = 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<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();
|
||||
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
123
src/notifications/factory.rs
Normal file
123
src/notifications/factory.rs
Normal file
|
|
@ -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<C, N, F>(
|
||||
config_opt: &Option<C>,
|
||||
creator: F,
|
||||
) -> Result<Option<Box<dyn Notification>>>
|
||||
where
|
||||
C: Enableable + Clone,
|
||||
N: Notification + 'static,
|
||||
F: FnOnce(C) -> Result<N>,
|
||||
{
|
||||
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<String, crate::actions::action::ProcessedMagnets>,
|
||||
_total_new_links: usize,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_notification_no_config() {
|
||||
let config: Option<FakeConfig> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod factory;
|
||||
pub mod notification;
|
||||
pub mod ntfy;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, ProcessedMagnets>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue