Merge branch 'validation' into 'main'
feat: validate configuration See merge request kernald/reddit-magnet!34
This commit is contained in:
commit
8b3baad1f8
33 changed files with 1076 additions and 165 deletions
107
Cargo.lock
generated
107
Cargo.lock
generated
|
|
@ -312,6 +312,15 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -530,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1107,6 +1116,17 @@ version = "0.1.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.43.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
|
||||
dependencies = [
|
||||
"console",
|
||||
"once_cell",
|
||||
"similar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
|
|
@ -1121,7 +1141,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
|||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1146,6 +1166,27 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kinded"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce4bdbb2f423660b19f0e9f7115182214732d8dd5f840cd0a3aee3e22562f34c"
|
||||
dependencies = [
|
||||
"kinded_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kinded_macros"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13b4ddc5dcb32f45dac3d6f606da2a52fdb9964a18427e63cd5ef6c0d13288d"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
|
|
@ -1324,6 +1365,31 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nutype"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3340cb6773b0794ecb3f62ff66631d580f57151d9415c10ee8a27a357aeb998b"
|
||||
dependencies = [
|
||||
"nutype_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nutype_macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35c955e27d02868fe90b9c2dc901661fd7ed67ec382782bdc67c6aa8d2e957a9"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"kinded",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.32.2"
|
||||
|
|
@ -1540,7 +1606,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1602,10 +1668,12 @@ dependencies = [
|
|||
"directories",
|
||||
"figment",
|
||||
"figment_file_provider_adapter",
|
||||
"insta",
|
||||
"log",
|
||||
"magnet-url",
|
||||
"multimap",
|
||||
"ntfy",
|
||||
"nutype",
|
||||
"pretty_env_logger",
|
||||
"regex",
|
||||
"reqwest 0.12.15",
|
||||
|
|
@ -1787,6 +1855,15 @@ version = "2.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.5"
|
||||
|
|
@ -1797,7 +1874,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1896,6 +1973,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
|
|
@ -1975,6 +2058,12 @@ version = "1.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
|
|
@ -2107,7 +2196,7 @@ dependencies = [
|
|||
"getrandom 0.3.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2443,6 +2532,12 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2639,7 +2734,7 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -34,3 +34,5 @@ reqwest = "0.12.15"
|
|||
magnet-url = "2.0.0"
|
||||
urlencoding = "2.1.3"
|
||||
ntfy = "0.7.0"
|
||||
nutype = { version = "0.6.1", features = ["serde", "regex"] }
|
||||
insta = "1.43.1"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
rustPlatform.rustcSrc
|
||||
] ++ lib.optionals stdenv.isDarwin [
|
||||
libiconv
|
||||
darwin.apple_sdk.frameworks.SystemConfiguration
|
||||
];
|
||||
};
|
||||
in
|
||||
|
|
@ -101,6 +100,7 @@
|
|||
buildInputs = with pkgs; [
|
||||
cargo
|
||||
cargo-edit
|
||||
cargo-insta
|
||||
cargo-machete
|
||||
cargo-release
|
||||
cargo-sort
|
||||
|
|
@ -114,7 +114,6 @@
|
|||
sqlite
|
||||
] ++ lib.optionals stdenv.isDarwin [
|
||||
libiconv
|
||||
darwin.apple_sdk.frameworks.SystemConfiguration
|
||||
] ++ self.checks.${system}.pre-commit-check.enabledPackages;
|
||||
RUST_BACKTRACE = 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
use crate::app::Enableable;
|
||||
use crate::config::types::Host;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for the Bitmagnet action
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BitmagnetConfig {
|
||||
pub enable: bool,
|
||||
pub host: String,
|
||||
pub host: Host,
|
||||
}
|
||||
|
||||
impl Enableable for BitmagnetConfig {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod action;
|
||||
mod client;
|
||||
mod config;
|
||||
mod validation;
|
||||
|
||||
pub use action::BitmagnetAction;
|
||||
pub use config::BitmagnetConfig;
|
||||
|
|
|
|||
48
src/actions/bitmagnet/validation.rs
Normal file
48
src/actions/bitmagnet/validation.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use crate::actions::bitmagnet::BitmagnetConfig;
|
||||
use crate::config::Validate;
|
||||
use crate::errors::{self};
|
||||
use color_eyre::eyre::Result;
|
||||
use url::Url;
|
||||
|
||||
impl Validate for BitmagnetConfig {
|
||||
fn validate(&self) -> Result<()> {
|
||||
Url::parse(&self.host.to_string())
|
||||
.map_err(|e| errors::config_error(e, &format!("Invalid URL format: {}", self.host)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::Host;
|
||||
|
||||
fn create_valid_config() -> Result<BitmagnetConfig> {
|
||||
Ok(BitmagnetConfig {
|
||||
enable: true,
|
||||
host: Host::try_new("http://localhost:8080")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config() -> Result<()> {
|
||||
let config = create_valid_config()?;
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_url() -> Result<()> {
|
||||
let config = BitmagnetConfig {
|
||||
enable: true,
|
||||
host: Host::try_new("not a url")?,
|
||||
};
|
||||
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ where
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::action::ProcessedMagnets;
|
||||
use crate::db::Database;
|
||||
use crate::models::Magnet;
|
||||
use crate::services::database::DatabaseService;
|
||||
use async_trait::async_trait;
|
||||
|
|
|
|||
|
|
@ -23,15 +23,15 @@ impl TransmissionClient {
|
|||
let url = Url::parse(&url_str).wrap_err_with(|| format!("Invalid URL: {}", url_str))?;
|
||||
|
||||
let auth = BasicAuth {
|
||||
user: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
user: config.username.to_string(),
|
||||
password: config.password.to_string(),
|
||||
};
|
||||
|
||||
let client = TransClient::with_auth(url, auth);
|
||||
|
||||
Ok(TransmissionClient {
|
||||
client,
|
||||
download_dir: config.download_dir.clone(),
|
||||
download_dir: config.download_dir.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
use crate::app::Enableable;
|
||||
use crate::config::types::{AbsoluteDownloadDir, Host, Password, Port, Username};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for the Transmission action
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransmissionConfig {
|
||||
pub enable: bool,
|
||||
pub host: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub port: u16,
|
||||
pub download_dir: String,
|
||||
pub host: Host,
|
||||
pub username: Username,
|
||||
pub password: Password,
|
||||
pub port: Port,
|
||||
pub download_dir: AbsoluteDownloadDir,
|
||||
}
|
||||
|
||||
impl Enableable for TransmissionConfig {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod action;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod validation;
|
||||
|
||||
pub use action::TransmissionAction;
|
||||
pub use config::TransmissionConfig;
|
||||
|
|
|
|||
33
src/actions/transmission/validation.rs
Normal file
33
src/actions/transmission/validation.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
use crate::actions::transmission::TransmissionConfig;
|
||||
use crate::config::Validate;
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
impl Validate for TransmissionConfig {
|
||||
fn validate(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{AbsoluteDownloadDir, Host, Password, Port, Username};
|
||||
|
||||
fn create_valid_config() -> Result<TransmissionConfig> {
|
||||
Ok(TransmissionConfig {
|
||||
enable: true,
|
||||
host: Host::try_new("localhost")?,
|
||||
username: Username::try_new("user")?,
|
||||
password: Password::try_new("pass")?,
|
||||
port: Port::try_new(9091)?,
|
||||
download_dir: AbsoluteDownloadDir::try_new("/downloads")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config() -> Result<()> {
|
||||
let config = create_valid_config()?;
|
||||
assert!(config.validate().is_ok());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::actions::action::ProcessedMagnets;
|
||||
use crate::config::types::RedditUsername;
|
||||
use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
use crate::reddit_client::RedditPost;
|
||||
|
|
@ -56,7 +57,10 @@ impl App {
|
|||
}
|
||||
|
||||
/// Fetch posts from Reddit
|
||||
pub async fn fetch_posts(&self, post_count: u32) -> Result<MultiMap<String, RedditPost>> {
|
||||
pub async fn fetch_posts(
|
||||
&self,
|
||||
post_count: u32,
|
||||
) -> Result<MultiMap<RedditUsername, RedditPost>> {
|
||||
let mut unique_usernames = HashSet::new();
|
||||
for source_config in self.config.sources.values() {
|
||||
unique_usernames.insert(source_config.username.clone());
|
||||
|
|
@ -73,7 +77,7 @@ impl App {
|
|||
/// Process sources and extract magnet links
|
||||
pub async fn process_sources(
|
||||
&mut self,
|
||||
user_posts: &MultiMap<String, RedditPost>,
|
||||
user_posts: &MultiMap<RedditUsername, RedditPost>,
|
||||
) -> Result<usize> {
|
||||
self.post_processor
|
||||
.process_sources(user_posts, &self.config.sources)
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
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;
|
||||
use figment::{
|
||||
providers::{Format, Toml},
|
||||
Figment,
|
||||
};
|
||||
use figment_file_provider_adapter::FileAdapter;
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Configuration for a Reddit source
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SourceConfig {
|
||||
pub username: String,
|
||||
pub title_filter: Option<String>,
|
||||
pub imdb_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Main application configuration
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bitmagnet: Option<BitmagnetConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub transmission: Option<TransmissionConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub ntfy: Option<NtfyConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub sources: HashMap<String, SourceConfig>,
|
||||
}
|
||||
|
||||
/// Loads the configuration from the specified file or default location
|
||||
pub fn load_config(args: &Args) -> Result<Config> {
|
||||
let mut conf_extractor = Figment::new();
|
||||
let config_file_path: Option<PathBuf> = 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."));
|
||||
}
|
||||
|
||||
Ok(conf)
|
||||
}
|
||||
|
||||
/// Gets the database path from the command line arguments or default location
|
||||
pub fn get_db_path(args: &Args) -> Result<PathBuf> {
|
||||
match &args.db {
|
||||
Some(path) => Ok(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")),
|
||||
}
|
||||
}
|
||||
28
src/config/fixtures/test_config.toml
Normal file
28
src/config/fixtures/test_config.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[bitmagnet]
|
||||
enable = true
|
||||
host = "http://localhost:8080"
|
||||
|
||||
[transmission]
|
||||
enable = false
|
||||
host = "localhost"
|
||||
port = 9091
|
||||
username = "admin"
|
||||
password = "password"
|
||||
download_dir = "/downloads"
|
||||
|
||||
[ntfy]
|
||||
enable = true
|
||||
topic = "reddit-magnet"
|
||||
host = "https://ntfy.sh"
|
||||
priority = "default"
|
||||
|
||||
[sources.movies]
|
||||
username = "movie_poster"
|
||||
title_filter = "\\[1080p\\]"
|
||||
tags = ["movies", "hd"]
|
||||
|
||||
[sources.tv_shows]
|
||||
username = "tv_shows_fan"
|
||||
title_filter = "S\\d{2}E\\d{2}"
|
||||
imdb_id = "tt1234567"
|
||||
tags = ["tv", "series"]
|
||||
238
src/config/lib.rs
Normal file
238
src/config/lib.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
use crate::actions::bitmagnet::BitmagnetConfig;
|
||||
use crate::actions::transmission::TransmissionConfig;
|
||||
use crate::app::Enableable;
|
||||
use crate::args::Args;
|
||||
use crate::config::types::{ImdbId, RedditUsername, TitleFilter};
|
||||
use crate::config::validation::Validate;
|
||||
use crate::errors::{self};
|
||||
use crate::notifications::ntfy::NtfyConfig;
|
||||
use color_eyre::eyre::Result;
|
||||
use directories::ProjectDirs;
|
||||
use figment::providers::Env;
|
||||
use figment::{
|
||||
providers::{Format, Toml},
|
||||
Figment,
|
||||
};
|
||||
use figment_file_provider_adapter::FileAdapter;
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Configuration for a Reddit source
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SourceConfig {
|
||||
pub username: RedditUsername,
|
||||
pub title_filter: Option<TitleFilter>,
|
||||
pub imdb_id: Option<ImdbId>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Main application configuration
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bitmagnet: Option<BitmagnetConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub transmission: Option<TransmissionConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub ntfy: Option<NtfyConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub sources: BTreeMap<String, SourceConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Validates the entire configuration
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.sources.is_empty() {
|
||||
return Err(errors::config_error(
|
||||
"No sources found in configuration",
|
||||
"Please add at least one source to your configuration file",
|
||||
));
|
||||
}
|
||||
|
||||
for (name, source) in &self.sources {
|
||||
self.validate_component(source, &format!("Invalid source configuration: {}", name))?;
|
||||
}
|
||||
|
||||
self.validate_optional_component(&self.bitmagnet, "Invalid Bitmagnet configuration")?;
|
||||
self.validate_optional_component(&self.transmission, "Invalid Transmission configuration")?;
|
||||
self.validate_optional_component(&self.ntfy, "Invalid Ntfy configuration")?;
|
||||
|
||||
let has_enabled_action = (self.bitmagnet.as_ref().is_some_and(|c| c.is_enabled()))
|
||||
|| (self.transmission.as_ref().is_some_and(|c| c.is_enabled()));
|
||||
|
||||
if !has_enabled_action {
|
||||
return Err(errors::config_error(
|
||||
"No actions are enabled",
|
||||
"Please enable at least one action in your configuration",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper method to validate a component
|
||||
fn validate_component<T: Validate>(&self, component: &T, error_context: &str) -> Result<()> {
|
||||
component
|
||||
.validate()
|
||||
.map_err(|e| errors::config_error(e, error_context))
|
||||
}
|
||||
|
||||
/// Helper method to validate an optional component
|
||||
fn validate_optional_component<T: Validate + Enableable>(
|
||||
&self,
|
||||
component: &Option<T>,
|
||||
error_context: &str,
|
||||
) -> Result<()> {
|
||||
if let Some(component) = component {
|
||||
component
|
||||
.validate_if_enabled()
|
||||
.map_err(|e| errors::config_error(e, error_context))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the configuration from the specified file or default location
|
||||
pub fn load_config(args: &Args) -> Result<Config> {
|
||||
let mut conf_extractor = Figment::new();
|
||||
let config_file_path: Option<PathBuf> = 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()
|
||||
.map_err(|e| {
|
||||
errors::config_error(
|
||||
e,
|
||||
"Invalid configuration or insufficient command line arguments",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate the configuration
|
||||
conf.validate()?;
|
||||
|
||||
Ok(conf)
|
||||
}
|
||||
|
||||
/// Gets the database path from the command line arguments or default location
|
||||
pub fn get_db_path(args: &Args) -> Result<PathBuf> {
|
||||
match &args.db {
|
||||
Some(path) => Ok(PathBuf::from(path)),
|
||||
None => ProjectDirs::from("fr", "enoent", "reddit-magnet")
|
||||
.map(|p| p.data_dir().join("reddit-magnet.db"))
|
||||
.ok_or_else(|| {
|
||||
errors::config_error("Could not determine data directory", "Database path")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::Host;
|
||||
use insta::assert_debug_snapshot;
|
||||
use std::path::Path;
|
||||
|
||||
/// Helper function to create a valid configuration for testing
|
||||
fn create_valid_config() -> Result<Config> {
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert(
|
||||
"test".to_string(),
|
||||
SourceConfig {
|
||||
username: RedditUsername::try_new("user123")?,
|
||||
title_filter: None,
|
||||
imdb_id: None,
|
||||
tags: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Config {
|
||||
bitmagnet: Some(BitmagnetConfig {
|
||||
enable: true,
|
||||
host: Host::try_new("http://localhost:8080")?,
|
||||
}),
|
||||
transmission: None,
|
||||
ntfy: None,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config() -> Result<()> {
|
||||
let config = create_valid_config()?;
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_no_sources() -> Result<()> {
|
||||
let mut invalid_config = create_valid_config()?;
|
||||
invalid_config.sources = BTreeMap::new();
|
||||
|
||||
assert!(invalid_config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_no_enabled_actions() -> Result<()> {
|
||||
let mut invalid_config = create_valid_config()?;
|
||||
if let Some(bitmagnet) = &mut invalid_config.bitmagnet {
|
||||
bitmagnet.enable = false;
|
||||
}
|
||||
|
||||
assert!(invalid_config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_source_username() -> Result<()> {
|
||||
match RedditUsername::try_new("u") {
|
||||
Ok(_) => panic!("Expected error for short username"),
|
||||
Err(e) => {
|
||||
assert!(e.to_string().contains("RedditUsername is too short. The value length must be more than 3 character(s)."));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_parsing_snapshot() -> Result<()> {
|
||||
let fixture_path = Path::new("src/config/fixtures/test_config.toml");
|
||||
let config_content = std::fs::read_to_string(fixture_path)?;
|
||||
|
||||
let config: Config = Figment::new()
|
||||
.merge(Toml::string(&config_content))
|
||||
.extract()?;
|
||||
|
||||
assert_debug_snapshot!("config_parsing", config);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
src/config/mod.rs
Normal file
6
src/config/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod lib;
|
||||
pub(crate) mod types;
|
||||
mod validation;
|
||||
|
||||
pub use lib::*;
|
||||
pub use validation::Validate;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
source: src/config/lib.rs
|
||||
assertion_line: 247
|
||||
expression: config
|
||||
---
|
||||
Config {
|
||||
bitmagnet: Some(
|
||||
BitmagnetConfig {
|
||||
enable: true,
|
||||
host: Host(
|
||||
"http://localhost:8080",
|
||||
),
|
||||
},
|
||||
),
|
||||
transmission: Some(
|
||||
TransmissionConfig {
|
||||
enable: false,
|
||||
host: Host(
|
||||
"localhost",
|
||||
),
|
||||
username: Username(
|
||||
"admin",
|
||||
),
|
||||
password: Password(
|
||||
"password",
|
||||
),
|
||||
port: Port(
|
||||
9091,
|
||||
),
|
||||
download_dir: AbsoluteDownloadDir(
|
||||
"/downloads",
|
||||
),
|
||||
},
|
||||
),
|
||||
ntfy: Some(
|
||||
NtfyConfig {
|
||||
enable: true,
|
||||
host: Host(
|
||||
"https://ntfy.sh",
|
||||
),
|
||||
username: None,
|
||||
password: None,
|
||||
topic: Topic(
|
||||
"reddit-magnet",
|
||||
),
|
||||
},
|
||||
),
|
||||
sources: {
|
||||
"movies": SourceConfig {
|
||||
username: RedditUsername(
|
||||
"movie_poster",
|
||||
),
|
||||
title_filter: Some(
|
||||
TitleFilter(
|
||||
"\\[1080p\\]",
|
||||
),
|
||||
),
|
||||
imdb_id: None,
|
||||
tags: [
|
||||
"movies",
|
||||
"hd",
|
||||
],
|
||||
},
|
||||
"tv_shows": SourceConfig {
|
||||
username: RedditUsername(
|
||||
"tv_shows_fan",
|
||||
),
|
||||
title_filter: Some(
|
||||
TitleFilter(
|
||||
"S\\d{2}E\\d{2}",
|
||||
),
|
||||
),
|
||||
imdb_id: Some(
|
||||
ImdbId(
|
||||
"tt1234567",
|
||||
),
|
||||
),
|
||||
tags: [
|
||||
"tv",
|
||||
"series",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
241
src/config/types.rs
Normal file
241
src/config/types.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use nutype::nutype;
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(len_char_min = 3, len_char_max = 20),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct RedditUsername(String);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(regex = r"^tt\d+$"),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct ImdbId(String);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(not_empty),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct Host(String);
|
||||
|
||||
#[nutype(
|
||||
validate(greater_or_equal = 1, less_or_equal = 65535),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct Port(u16);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(not_empty),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct Username(String);
|
||||
|
||||
#[nutype(
|
||||
validate(not_empty),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct Password(String);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(not_empty, predicate = is_absolute_path),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct AbsoluteDownloadDir(String);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(regex = "^[a-zA-Z0-9_-]+$"),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct Topic(String);
|
||||
|
||||
#[nutype(
|
||||
sanitize(trim),
|
||||
validate(not_empty, predicate = is_valid_regex),
|
||||
derive(
|
||||
Debug,
|
||||
Deref,
|
||||
Display,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone
|
||||
)
|
||||
)]
|
||||
pub struct TitleFilter(String);
|
||||
|
||||
fn is_valid_regex(s: &str) -> bool {
|
||||
use regex::Regex;
|
||||
Regex::new(s).is_ok()
|
||||
}
|
||||
|
||||
fn is_absolute_path(s: &str) -> bool {
|
||||
use std::path::Path;
|
||||
Path::new(s).is_absolute()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_host() {
|
||||
let result = Host::try_new("");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Host is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_username() {
|
||||
let result = Username::try_new("");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Username is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_download_dir() {
|
||||
let result = AbsoluteDownloadDir::try_new("");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("AbsoluteDownloadDir is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_download_dir() {
|
||||
let result = AbsoluteDownloadDir::try_new("downloads");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("AbsoluteDownloadDir failed the predicate test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_topic() {
|
||||
let result = Topic::try_new("");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Topic violated the regular expression"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_topic_format() {
|
||||
let result = Topic::try_new("invalid@topic");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Topic violated the regular expression"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_regex() {
|
||||
let result = TitleFilter::try_new(r"*invalid[");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("TitleFilter failed the predicate test."));
|
||||
}
|
||||
}
|
||||
47
src/config/validation.rs
Normal file
47
src/config/validation.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use crate::app::Enableable;
|
||||
use crate::config::SourceConfig;
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
/// Trait for configuration validation
|
||||
pub trait Validate {
|
||||
fn validate(&self) -> Result<()>;
|
||||
|
||||
/// Validates the configuration if it's enabled, otherwise returns Ok(())
|
||||
fn validate_if_enabled(&self) -> Result<()>
|
||||
where
|
||||
Self: Enableable,
|
||||
{
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for SourceConfig {
|
||||
fn validate(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{ImdbId, RedditUsername, TitleFilter};
|
||||
|
||||
fn create_valid_config() -> Result<SourceConfig> {
|
||||
Ok(SourceConfig {
|
||||
username: RedditUsername::try_new("user123")?,
|
||||
title_filter: Some(TitleFilter::try_new(r".*\.mkv$")?),
|
||||
imdb_id: Some(ImdbId::try_new("tt1234567")?),
|
||||
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config() -> Result<()> {
|
||||
let config = create_valid_config()?;
|
||||
assert!(config.validate().is_ok());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
22
src/db.rs
22
src/db.rs
|
|
@ -1,6 +1,7 @@
|
|||
use crate::models::{Magnet, NewBitmagnetProcessed, NewMagnet, NewTag, NewTransmissionProcessed};
|
||||
use crate::schema::{bitmagnet_processed, magnet_tags, magnets, tags, transmission_processed};
|
||||
use crate::PostInfo;
|
||||
use chrono::Utc;
|
||||
use color_eyre::eyre::{eyre, Result, WrapErr};
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
|
|
@ -28,7 +29,7 @@ impl ProcessedTable for BitmagnetProcessedTable {
|
|||
}
|
||||
|
||||
fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let now = Utc::now().naive_utc();
|
||||
let new_processed = NewBitmagnetProcessed {
|
||||
magnet_id,
|
||||
processed_at: &now,
|
||||
|
|
@ -52,7 +53,7 @@ impl ProcessedTable for TransmissionProcessedTable {
|
|||
}
|
||||
|
||||
fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let now = Utc::now().naive_utc();
|
||||
let new_processed = NewTransmissionProcessed {
|
||||
magnet_id,
|
||||
processed_at: &now,
|
||||
|
|
@ -125,7 +126,7 @@ impl Database {
|
|||
subreddit: post.subreddit.as_str(),
|
||||
link: m,
|
||||
published_at: &published_at,
|
||||
imdb_id: post.imdb_id.as_deref(),
|
||||
imdb_id: post.imdb_id.as_deref().map(|s| s.as_str()),
|
||||
})
|
||||
.collect::<Vec<NewMagnet>>();
|
||||
|
||||
|
|
@ -222,6 +223,7 @@ impl Database {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{ImdbId, RedditUsername};
|
||||
use crate::PostInfo;
|
||||
use chrono::Utc;
|
||||
use tempfile::tempdir;
|
||||
|
|
@ -246,7 +248,7 @@ mod tests {
|
|||
|
||||
let post_info = PostInfo {
|
||||
title: "Test Title".to_string(),
|
||||
submitter: "test_user".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||
subreddit: "test_subreddit".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test1".to_string(),
|
||||
|
|
@ -286,7 +288,7 @@ mod tests {
|
|||
// Create initial post with magnet links
|
||||
let post_info = PostInfo {
|
||||
title: "Test Title".to_string(),
|
||||
submitter: "test_user".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||
subreddit: "test_subreddit".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test1".to_string(),
|
||||
|
|
@ -305,14 +307,14 @@ mod tests {
|
|||
// Create a second post with some duplicate and some new links
|
||||
let post_info2 = PostInfo {
|
||||
title: "Test Title 2".to_string(),
|
||||
submitter: "test_user2".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user2").unwrap(),
|
||||
subreddit: "test_subreddit2".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
||||
"magnet:?xt=urn:btih:test3".to_string(), // New
|
||||
],
|
||||
timestamp: Utc::now(),
|
||||
imdb_id: Some("tt1234567".to_string()),
|
||||
imdb_id: Some(ImdbId::try_new("tt1234567").unwrap()),
|
||||
tags: vec!["test".to_string()],
|
||||
};
|
||||
|
||||
|
|
@ -328,7 +330,7 @@ mod tests {
|
|||
// Try inserting only duplicates
|
||||
let post_info3 = PostInfo {
|
||||
title: "Test Title 3".to_string(),
|
||||
submitter: "test_user3".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user3").unwrap(),
|
||||
subreddit: "test_subreddit3".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
||||
|
|
@ -359,7 +361,7 @@ mod tests {
|
|||
// Create a post with tags
|
||||
let post_info = PostInfo {
|
||||
title: "Test Title with Tags".to_string(),
|
||||
submitter: "test_user".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||
subreddit: "test_subreddit".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test_tag1".to_string(),
|
||||
|
|
@ -390,7 +392,7 @@ mod tests {
|
|||
// Test adding a new magnet with some duplicate and some new tags
|
||||
let post_info2 = PostInfo {
|
||||
title: "Test Title with More Tags".to_string(),
|
||||
submitter: "test_user".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||
subreddit: "test_subreddit".to_string(),
|
||||
magnet_links: vec!["magnet:?xt=urn:btih:test_tag3".to_string()],
|
||||
timestamp: Utc::now(),
|
||||
|
|
|
|||
47
src/errors.rs
Normal file
47
src/errors.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use color_eyre::section::Section;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ErrorCategory {
|
||||
Database,
|
||||
Config,
|
||||
Reddit,
|
||||
Action,
|
||||
Notification,
|
||||
IO,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCategory {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ErrorCategory::Database => write!(f, "Database error"),
|
||||
ErrorCategory::Config => write!(f, "Configuration error"),
|
||||
ErrorCategory::Reddit => write!(f, "Reddit API error"),
|
||||
ErrorCategory::Action => write!(f, "Action error"),
|
||||
ErrorCategory::Notification => write!(f, "Notification error"),
|
||||
ErrorCategory::IO => write!(f, "IO error"),
|
||||
ErrorCategory::Unknown => write!(f, "Unknown error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! define_error_fn {
|
||||
($fn_name:ident, $category:expr) => {
|
||||
/// Helper function to create an error with context
|
||||
#[allow(dead_code)]
|
||||
pub fn $fn_name<E: std::fmt::Display>(error: E, context: &str) -> color_eyre::eyre::Report {
|
||||
color_eyre::eyre::eyre!("{}: {}", context, error).with_section(|| $category.to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Define all error helper functions using the macro
|
||||
define_error_fn!(db_error, ErrorCategory::Database);
|
||||
define_error_fn!(config_error, ErrorCategory::Config);
|
||||
define_error_fn!(reddit_error, ErrorCategory::Reddit);
|
||||
define_error_fn!(action_error, ErrorCategory::Action);
|
||||
define_error_fn!(notification_error, ErrorCategory::Notification);
|
||||
define_error_fn!(io_error, ErrorCategory::IO);
|
||||
23
src/main.rs
23
src/main.rs
|
|
@ -1,10 +1,11 @@
|
|||
use crate::app::App;
|
||||
use crate::args::Args;
|
||||
use crate::config::types::{ImdbId, RedditUsername};
|
||||
use crate::config::{get_db_path, load_config};
|
||||
use crate::db::Database;
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use color_eyre::eyre::Result;
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
mod actions;
|
||||
|
|
@ -12,6 +13,7 @@ mod app;
|
|||
mod args;
|
||||
mod config;
|
||||
mod db;
|
||||
mod errors;
|
||||
mod magnet;
|
||||
mod models;
|
||||
mod notifications;
|
||||
|
|
@ -24,18 +26,18 @@ mod services;
|
|||
#[derive(Debug)]
|
||||
pub struct PostInfo {
|
||||
pub title: String,
|
||||
pub submitter: String,
|
||||
pub submitter: RedditUsername,
|
||||
pub magnet_links: Vec<magnet::Magnet>,
|
||||
pub subreddit: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub imdb_id: Option<String>,
|
||||
pub imdb_id: Option<ImdbId>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize error handling
|
||||
color_eyre::install()?;
|
||||
color_eyre::install().map_err(|e| errors::db_error(e, "Failed to install error handler"))?;
|
||||
|
||||
// Parse command-line arguments
|
||||
let args = Args::parse();
|
||||
|
|
@ -50,12 +52,17 @@ async fn main() -> Result<()> {
|
|||
|
||||
// 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))?;
|
||||
create_dir_all(parent).map_err(|e| {
|
||||
errors::io_error(e, &format!("Failed to create directory: {:?}", parent))
|
||||
})?;
|
||||
}
|
||||
|
||||
let db = Database::new(&db_path)
|
||||
.wrap_err_with(|| format!("Failed to initialize database at {:?}", db_path))?;
|
||||
let db = Database::new(&db_path).map_err(|e| {
|
||||
errors::db_error(
|
||||
e,
|
||||
&format!("Failed to initialize database at {:?}", db_path),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Load configuration
|
||||
let conf = load_config(&args)?;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::app::Enableable;
|
||||
use crate::config::types::{Host, Password, Topic, Username};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for the ntfy notification service
|
||||
|
|
@ -9,18 +10,18 @@ pub struct NtfyConfig {
|
|||
pub enable: bool,
|
||||
|
||||
/// The host URL of the ntfy server
|
||||
pub host: String,
|
||||
pub host: Host,
|
||||
|
||||
/// The username for authentication (optional)
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
pub username: Option<Username>,
|
||||
|
||||
/// The password for authentication (optional)
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
pub password: Option<Password>,
|
||||
|
||||
/// The topic to publish notifications to
|
||||
pub topic: String,
|
||||
pub topic: Topic,
|
||||
}
|
||||
|
||||
impl Enableable for NtfyConfig {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod notification;
|
||||
pub mod validation;
|
||||
|
||||
pub use config::NtfyConfig;
|
||||
pub use notification::NtfyNotification;
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ pub struct NtfyNotification {
|
|||
impl NtfyNotification {
|
||||
/// Create a new NtfyNotification instance
|
||||
pub fn new(config: NtfyConfig) -> Result<Self> {
|
||||
let mut builder = dispatcher::builder(config.host.clone());
|
||||
let mut builder = dispatcher::builder(config.host.as_str());
|
||||
|
||||
// Add authentication if provided
|
||||
if let (Some(username), Some(password)) = (&config.username, &config.password) {
|
||||
builder = builder.credentials(Auth::credentials(username, password))
|
||||
builder = builder.credentials(Auth::credentials(
|
||||
username.to_string(),
|
||||
password.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
let client = builder.build_async()?;
|
||||
|
|
@ -57,7 +60,7 @@ impl Notification for NtfyNotification {
|
|||
Priority::Default
|
||||
};
|
||||
|
||||
let payload = Payload::new(&self.config.topic)
|
||||
let payload = Payload::new(self.config.topic.to_string())
|
||||
.message(report::generate_report(
|
||||
action_results,
|
||||
total_new_links,
|
||||
|
|
|
|||
88
src/notifications/ntfy/validation.rs
Normal file
88
src/notifications/ntfy/validation.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use crate::config::Validate;
|
||||
use crate::errors::{self};
|
||||
use crate::notifications::ntfy::NtfyConfig;
|
||||
use color_eyre::eyre::Result;
|
||||
use url::Url;
|
||||
|
||||
impl Validate for NtfyConfig {
|
||||
fn validate(&self) -> Result<()> {
|
||||
// Validate URL format
|
||||
Url::parse(&self.host.to_string())
|
||||
.map_err(|e| errors::config_error(e, &format!("Invalid URL format: {}", self.host)))?;
|
||||
|
||||
match (self.username.as_ref(), self.password.as_ref()) {
|
||||
(Some(_), None) => {
|
||||
return Err(errors::config_error(
|
||||
"Password must be provided when username is set",
|
||||
"NtfyConfig",
|
||||
))
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(errors::config_error(
|
||||
"Username must be provided when password is set",
|
||||
"NtfyConfig",
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{Host, Password, Topic, Username};
|
||||
|
||||
fn create_valid_config() -> Result<NtfyConfig> {
|
||||
Ok(NtfyConfig {
|
||||
enable: true,
|
||||
host: Host::try_new("https://ntfy.sh")?,
|
||||
username: None,
|
||||
password: None,
|
||||
topic: Topic::try_new("my-topic")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config() -> Result<()> {
|
||||
let config = create_valid_config()?;
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_url() -> Result<()> {
|
||||
let mut invalid_config = create_valid_config()?;
|
||||
invalid_config.host = Host::try_new("not a url")?;
|
||||
|
||||
assert!(invalid_config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_username_without_password() -> Result<()> {
|
||||
let mut invalid_config = create_valid_config()?;
|
||||
invalid_config.username = Some(Username::try_new("user")?);
|
||||
invalid_config.password = None;
|
||||
|
||||
assert!(invalid_config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_without_username() -> Result<()> {
|
||||
let mut invalid_config = create_valid_config()?;
|
||||
invalid_config.username = None;
|
||||
invalid_config.password = Some(Password::try_new("pass")?);
|
||||
|
||||
assert!(invalid_config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::config::types::RedditUsername;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use roux::util::FeedOption;
|
||||
|
|
@ -29,10 +30,10 @@ impl RedditClient {
|
|||
/// * `post_count` - The maximum number of posts to fetch
|
||||
pub async fn fetch_user_submissions(
|
||||
&self,
|
||||
username: &str,
|
||||
username: &RedditUsername,
|
||||
post_count: u32,
|
||||
) -> Result<Vec<RedditPost>> {
|
||||
let user = User::new(username);
|
||||
let user = User::new(username.as_str());
|
||||
let feed_option = FeedOption::new().limit(post_count);
|
||||
let submissions = user
|
||||
.submitted(Some(feed_option))
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ mod tests {
|
|||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ mod tests {
|
|||
bitmagnet: None,
|
||||
transmission: None,
|
||||
ntfy: None,
|
||||
sources: HashMap::new(),
|
||||
sources: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let result = action_service.init_actions(&config);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use crate::db::{Database, ProcessedTable};
|
||||
use crate::errors::{self};
|
||||
use crate::models;
|
||||
use crate::PostInfo;
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use color_eyre::eyre::Result;
|
||||
use log::debug;
|
||||
|
||||
/// Service for database operations
|
||||
|
|
@ -31,9 +32,12 @@ impl DatabaseService {
|
|||
post_info.title
|
||||
);
|
||||
|
||||
self.db
|
||||
.store_magnets(post_info)
|
||||
.wrap_err_with(|| format!("Failed to store magnets for post: {}", post_info.title))
|
||||
self.db.store_magnets(post_info).map_err(|e| {
|
||||
errors::db_error(
|
||||
e,
|
||||
&format!("Failed to store magnets for post: {}", post_info.title),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all magnet links from the database
|
||||
|
|
@ -47,7 +51,7 @@ impl DatabaseService {
|
|||
|
||||
self.db
|
||||
.get_all_magnets()
|
||||
.wrap_err("Failed to retrieve all magnet links from database")
|
||||
.map_err(|e| errors::db_error(e, "Failed to retrieve all magnet links from database"))
|
||||
}
|
||||
|
||||
/// Get tags for a magnet link
|
||||
|
|
@ -62,9 +66,12 @@ impl DatabaseService {
|
|||
pub fn get_tags_for_magnet(&mut self, magnet_id: i32) -> Result<Vec<String>> {
|
||||
debug!("Retrieving tags for magnet ID: {}", magnet_id);
|
||||
|
||||
self.db
|
||||
.get_tags_for_magnet(magnet_id)
|
||||
.wrap_err_with(|| format!("Failed to retrieve tags for magnet ID: {}", magnet_id))
|
||||
self.db.get_tags_for_magnet(magnet_id).map_err(|e| {
|
||||
errors::db_error(
|
||||
e,
|
||||
&format!("Failed to retrieve tags for magnet ID: {}", magnet_id),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get unprocessed magnet links for a specific action
|
||||
|
|
@ -77,7 +84,7 @@ impl DatabaseService {
|
|||
|
||||
self.db
|
||||
.get_unprocessed_magnets_for_table::<T>()
|
||||
.wrap_err("Failed to retrieve unprocessed magnet links")
|
||||
.map_err(|e| errors::db_error(e, "Failed to retrieve unprocessed magnet links"))
|
||||
}
|
||||
|
||||
/// Mark a magnet link as processed for a specific action
|
||||
|
|
@ -90,37 +97,45 @@ impl DatabaseService {
|
|||
|
||||
self.db
|
||||
.mark_magnet_processed_for_table::<T>(magnet_id)
|
||||
.wrap_err_with(|| format!("Failed to mark magnet ID {} as processed", magnet_id))
|
||||
.map_err(|e| {
|
||||
errors::db_error(
|
||||
e,
|
||||
&format!("Failed to mark magnet ID {} as processed", magnet_id),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::{ImdbId, RedditUsername};
|
||||
use chrono::Utc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_test_post_info() -> PostInfo {
|
||||
PostInfo {
|
||||
title: "Test Post".to_string(),
|
||||
submitter: "test_user".to_string(),
|
||||
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||
subreddit: "test_subreddit".to_string(),
|
||||
magnet_links: vec![
|
||||
"magnet:?xt=urn:btih:test1".to_string(),
|
||||
"magnet:?xt=urn:btih:test2".to_string(),
|
||||
],
|
||||
timestamp: Utc::now(),
|
||||
imdb_id: Some("tt1234567".to_string()),
|
||||
imdb_id: Some(ImdbId::try_new("tt1234567").unwrap()),
|
||||
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_retrieve_magnets() -> Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let temp_dir =
|
||||
tempdir().map_err(|e| errors::io_error(e, "Failed to create temp directory"))?;
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
let db = Database::new(&db_path)?;
|
||||
let db = Database::new(&db_path)
|
||||
.map_err(|e| errors::db_error(e, "Failed to create test database"))?;
|
||||
let mut service = DatabaseService::new(db);
|
||||
|
||||
let post_info = create_test_post_info();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ mod tests {
|
|||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -172,7 +173,7 @@ mod tests {
|
|||
bitmagnet: None,
|
||||
transmission: None,
|
||||
ntfy: None,
|
||||
sources: HashMap::new(),
|
||||
sources: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let result = notification_service.init_notifications(&config);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ use color_eyre::eyre::{Result, WrapErr};
|
|||
use log::{info, warn};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::config::types::RedditUsername;
|
||||
use crate::config::SourceConfig;
|
||||
use crate::reddit_client::RedditPost;
|
||||
use multimap::MultiMap;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Service for processing posts and extracting magnet links
|
||||
|
|
@ -29,8 +34,8 @@ impl PostProcessorService {
|
|||
/// The number of new magnet links stored
|
||||
pub fn process_sources(
|
||||
&mut self,
|
||||
user_posts: &multimap::MultiMap<String, crate::reddit_client::RedditPost>,
|
||||
source_configs: &std::collections::HashMap<String, crate::config::SourceConfig>,
|
||||
user_posts: &MultiMap<RedditUsername, RedditPost>,
|
||||
source_configs: &BTreeMap<String, SourceConfig>,
|
||||
) -> Result<usize> {
|
||||
let mut total_new_links = 0;
|
||||
|
||||
|
|
@ -100,12 +105,12 @@ fn filter_post(title: &str, title_filter: Option<Regex>) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::types::ImdbId;
|
||||
use crate::config::SourceConfig;
|
||||
use crate::db::Database;
|
||||
use crate::reddit_client::RedditPost;
|
||||
use chrono::Utc;
|
||||
use multimap::MultiMap;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
|
|
@ -119,7 +124,7 @@ mod tests {
|
|||
let mut service = PostProcessorService::new(db_service);
|
||||
|
||||
let mut user_posts = MultiMap::new();
|
||||
let mut source_configs = HashMap::new();
|
||||
let mut source_configs = BTreeMap::new();
|
||||
|
||||
let post = RedditPost {
|
||||
title: "Test Post".to_string(),
|
||||
|
|
@ -127,17 +132,17 @@ mod tests {
|
|||
subreddit: "test_subreddit".to_string(),
|
||||
created: Utc::now(),
|
||||
};
|
||||
user_posts.insert("test_user".to_string(), post);
|
||||
user_posts.insert(RedditUsername::try_new("test_user")?, post);
|
||||
|
||||
let source_config = SourceConfig {
|
||||
username: "test_user".to_string(),
|
||||
username: RedditUsername::try_new("test_user")?,
|
||||
title_filter: None,
|
||||
imdb_id: Some("tt1234567".to_string()),
|
||||
imdb_id: Some(ImdbId::try_new("tt1234567")?),
|
||||
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
};
|
||||
source_configs.insert("test_source".to_string(), source_config);
|
||||
|
||||
let count = service.process_sources(&mut user_posts, &mut source_configs)?;
|
||||
let count = service.process_sources(&user_posts, &source_configs)?;
|
||||
|
||||
assert_eq!(count, 2, "Should have stored 2 new magnet links");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::config::types::RedditUsername;
|
||||
use crate::reddit_client::{RedditClient, RedditPost};
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use log::{debug, error, info, warn};
|
||||
|
|
@ -56,9 +57,9 @@ impl RedditService {
|
|||
/// A MultiMap mapping usernames to their posts
|
||||
pub async fn fetch_posts_from_users(
|
||||
&self,
|
||||
usernames: HashSet<String>,
|
||||
usernames: HashSet<RedditUsername>,
|
||||
post_count: u32,
|
||||
) -> Result<MultiMap<String, RedditPost>> {
|
||||
) -> Result<MultiMap<RedditUsername, RedditPost>> {
|
||||
let mut user_posts = MultiMap::new();
|
||||
|
||||
for username in usernames {
|
||||
|
|
@ -93,7 +94,7 @@ impl RedditService {
|
|||
/// A vector of RedditPost objects
|
||||
async fn fetch_user_posts_with_retry(
|
||||
&self,
|
||||
username: &str,
|
||||
username: &RedditUsername,
|
||||
post_count: u32,
|
||||
) -> Result<Vec<RedditPost>> {
|
||||
let mut attempts = 0;
|
||||
|
|
|
|||
|
|
@ -27,13 +27,10 @@ impl ReportService {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::action::ProcessedMagnets;
|
||||
use crate::models::Magnet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_new_report_service() {
|
||||
let report_service = ReportService::new();
|
||||
let _report_service = ReportService::new();
|
||||
// Just verify that we can create the service
|
||||
// The actual report generation is tested in the report module
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue