feat: validate configuration
This commit is contained in:
parent
ee7d7971be
commit
48c670f455
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",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
|
|
@ -530,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
|
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1107,6 +1116,17 @@ version = "0.1.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
|
|
@ -1121,7 +1141,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1146,6 +1166,27 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -1324,6 +1365,31 @@ dependencies = [
|
||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.32.2"
|
version = "0.32.2"
|
||||||
|
|
@ -1540,7 +1606,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1602,10 +1668,12 @@ dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"figment",
|
"figment",
|
||||||
"figment_file_provider_adapter",
|
"figment_file_provider_adapter",
|
||||||
|
"insta",
|
||||||
"log",
|
"log",
|
||||||
"magnet-url",
|
"magnet-url",
|
||||||
"multimap",
|
"multimap",
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
"nutype",
|
||||||
"pretty_env_logger",
|
"pretty_env_logger",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.15",
|
"reqwest 0.12.15",
|
||||||
|
|
@ -1787,6 +1855,15 @@ version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
@ -1797,7 +1874,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1896,6 +1973,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.219"
|
||||||
|
|
@ -1975,6 +2058,12 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
|
|
@ -2107,7 +2196,7 @@ dependencies = [
|
||||||
"getrandom 0.3.2",
|
"getrandom 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2443,6 +2532,12 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2639,7 +2734,7 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,5 @@ reqwest = "0.12.15"
|
||||||
magnet-url = "2.0.0"
|
magnet-url = "2.0.0"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
ntfy = "0.7.0"
|
ntfy = "0.7.0"
|
||||||
|
nutype = { version = "0.6.1", features = ["serde", "regex"] }
|
||||||
|
insta = "1.43.1"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@
|
||||||
rustPlatform.rustcSrc
|
rustPlatform.rustcSrc
|
||||||
] ++ lib.optionals stdenv.isDarwin [
|
] ++ lib.optionals stdenv.isDarwin [
|
||||||
libiconv
|
libiconv
|
||||||
darwin.apple_sdk.frameworks.SystemConfiguration
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
|
|
@ -101,6 +100,7 @@
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
cargo
|
cargo
|
||||||
cargo-edit
|
cargo-edit
|
||||||
|
cargo-insta
|
||||||
cargo-machete
|
cargo-machete
|
||||||
cargo-release
|
cargo-release
|
||||||
cargo-sort
|
cargo-sort
|
||||||
|
|
@ -114,7 +114,6 @@
|
||||||
sqlite
|
sqlite
|
||||||
] ++ lib.optionals stdenv.isDarwin [
|
] ++ lib.optionals stdenv.isDarwin [
|
||||||
libiconv
|
libiconv
|
||||||
darwin.apple_sdk.frameworks.SystemConfiguration
|
|
||||||
] ++ self.checks.${system}.pre-commit-check.enabledPackages;
|
] ++ self.checks.${system}.pre-commit-check.enabledPackages;
|
||||||
RUST_BACKTRACE = 1;
|
RUST_BACKTRACE = 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use crate::app::Enableable;
|
use crate::app::Enableable;
|
||||||
|
use crate::config::types::Host;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Configuration for the Bitmagnet action
|
/// Configuration for the Bitmagnet action
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BitmagnetConfig {
|
pub struct BitmagnetConfig {
|
||||||
pub enable: bool,
|
pub enable: bool,
|
||||||
pub host: String,
|
pub host: Host,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Enableable for BitmagnetConfig {
|
impl Enableable for BitmagnetConfig {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod action;
|
mod action;
|
||||||
mod client;
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
pub use action::BitmagnetAction;
|
pub use action::BitmagnetAction;
|
||||||
pub use config::BitmagnetConfig;
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::actions::action::ProcessedMagnets;
|
use crate::actions::action::ProcessedMagnets;
|
||||||
use crate::db::Database;
|
|
||||||
use crate::models::Magnet;
|
use crate::models::Magnet;
|
||||||
use crate::services::database::DatabaseService;
|
use crate::services::database::DatabaseService;
|
||||||
use async_trait::async_trait;
|
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 url = Url::parse(&url_str).wrap_err_with(|| format!("Invalid URL: {}", url_str))?;
|
||||||
|
|
||||||
let auth = BasicAuth {
|
let auth = BasicAuth {
|
||||||
user: config.username.clone(),
|
user: config.username.to_string(),
|
||||||
password: config.password.clone(),
|
password: config.password.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = TransClient::with_auth(url, auth);
|
let client = TransClient::with_auth(url, auth);
|
||||||
|
|
||||||
Ok(TransmissionClient {
|
Ok(TransmissionClient {
|
||||||
client,
|
client,
|
||||||
download_dir: config.download_dir.clone(),
|
download_dir: config.download_dir.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
use crate::app::Enableable;
|
use crate::app::Enableable;
|
||||||
|
use crate::config::types::{AbsoluteDownloadDir, Host, Password, Port, Username};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Configuration for the Transmission action
|
/// Configuration for the Transmission action
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TransmissionConfig {
|
pub struct TransmissionConfig {
|
||||||
pub enable: bool,
|
pub enable: bool,
|
||||||
pub host: String,
|
pub host: Host,
|
||||||
pub username: String,
|
pub username: Username,
|
||||||
pub password: String,
|
pub password: Password,
|
||||||
pub port: u16,
|
pub port: Port,
|
||||||
pub download_dir: String,
|
pub download_dir: AbsoluteDownloadDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Enableable for TransmissionConfig {
|
impl Enableable for TransmissionConfig {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
pub use action::TransmissionAction;
|
pub use action::TransmissionAction;
|
||||||
pub use config::TransmissionConfig;
|
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::actions::action::ProcessedMagnets;
|
||||||
|
use crate::config::types::RedditUsername;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::reddit_client::RedditPost;
|
use crate::reddit_client::RedditPost;
|
||||||
|
|
@ -56,7 +57,10 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch posts from Reddit
|
/// 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();
|
let mut unique_usernames = HashSet::new();
|
||||||
for source_config in self.config.sources.values() {
|
for source_config in self.config.sources.values() {
|
||||||
unique_usernames.insert(source_config.username.clone());
|
unique_usernames.insert(source_config.username.clone());
|
||||||
|
|
@ -73,7 +77,7 @@ impl App {
|
||||||
/// Process sources and extract magnet links
|
/// Process sources and extract magnet links
|
||||||
pub async fn process_sources(
|
pub async fn process_sources(
|
||||||
&mut self,
|
&mut self,
|
||||||
user_posts: &MultiMap<String, RedditPost>,
|
user_posts: &MultiMap<RedditUsername, RedditPost>,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
self.post_processor
|
self.post_processor
|
||||||
.process_sources(user_posts, &self.config.sources)
|
.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::models::{Magnet, NewBitmagnetProcessed, NewMagnet, NewTag, NewTransmissionProcessed};
|
||||||
use crate::schema::{bitmagnet_processed, magnet_tags, magnets, tags, transmission_processed};
|
use crate::schema::{bitmagnet_processed, magnet_tags, magnets, tags, transmission_processed};
|
||||||
use crate::PostInfo;
|
use crate::PostInfo;
|
||||||
|
use chrono::Utc;
|
||||||
use color_eyre::eyre::{eyre, Result, WrapErr};
|
use color_eyre::eyre::{eyre, Result, WrapErr};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
|
|
@ -28,7 +29,7 @@ impl ProcessedTable for BitmagnetProcessedTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> {
|
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 {
|
let new_processed = NewBitmagnetProcessed {
|
||||||
magnet_id,
|
magnet_id,
|
||||||
processed_at: &now,
|
processed_at: &now,
|
||||||
|
|
@ -52,7 +53,7 @@ impl ProcessedTable for TransmissionProcessedTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> {
|
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 {
|
let new_processed = NewTransmissionProcessed {
|
||||||
magnet_id,
|
magnet_id,
|
||||||
processed_at: &now,
|
processed_at: &now,
|
||||||
|
|
@ -125,7 +126,7 @@ impl Database {
|
||||||
subreddit: post.subreddit.as_str(),
|
subreddit: post.subreddit.as_str(),
|
||||||
link: m,
|
link: m,
|
||||||
published_at: &published_at,
|
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>>();
|
.collect::<Vec<NewMagnet>>();
|
||||||
|
|
||||||
|
|
@ -222,6 +223,7 @@ impl Database {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::types::{ImdbId, RedditUsername};
|
||||||
use crate::PostInfo;
|
use crate::PostInfo;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
@ -246,7 +248,7 @@ mod tests {
|
||||||
|
|
||||||
let post_info = PostInfo {
|
let post_info = PostInfo {
|
||||||
title: "Test Title".to_string(),
|
title: "Test Title".to_string(),
|
||||||
submitter: "test_user".to_string(),
|
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||||
subreddit: "test_subreddit".to_string(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test1".to_string(),
|
"magnet:?xt=urn:btih:test1".to_string(),
|
||||||
|
|
@ -286,7 +288,7 @@ mod tests {
|
||||||
// Create initial post with magnet links
|
// Create initial post with magnet links
|
||||||
let post_info = PostInfo {
|
let post_info = PostInfo {
|
||||||
title: "Test Title".to_string(),
|
title: "Test Title".to_string(),
|
||||||
submitter: "test_user".to_string(),
|
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||||
subreddit: "test_subreddit".to_string(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test1".to_string(),
|
"magnet:?xt=urn:btih:test1".to_string(),
|
||||||
|
|
@ -305,14 +307,14 @@ mod tests {
|
||||||
// Create a second post with some duplicate and some new links
|
// Create a second post with some duplicate and some new links
|
||||||
let post_info2 = PostInfo {
|
let post_info2 = PostInfo {
|
||||||
title: "Test Title 2".to_string(),
|
title: "Test Title 2".to_string(),
|
||||||
submitter: "test_user2".to_string(),
|
submitter: RedditUsername::try_new("test_user2").unwrap(),
|
||||||
subreddit: "test_subreddit2".to_string(),
|
subreddit: "test_subreddit2".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
||||||
"magnet:?xt=urn:btih:test3".to_string(), // New
|
"magnet:?xt=urn:btih:test3".to_string(), // New
|
||||||
],
|
],
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
imdb_id: Some("tt1234567".to_string()),
|
imdb_id: Some(ImdbId::try_new("tt1234567").unwrap()),
|
||||||
tags: vec!["test".to_string()],
|
tags: vec!["test".to_string()],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -328,7 +330,7 @@ mod tests {
|
||||||
// Try inserting only duplicates
|
// Try inserting only duplicates
|
||||||
let post_info3 = PostInfo {
|
let post_info3 = PostInfo {
|
||||||
title: "Test Title 3".to_string(),
|
title: "Test Title 3".to_string(),
|
||||||
submitter: "test_user3".to_string(),
|
submitter: RedditUsername::try_new("test_user3").unwrap(),
|
||||||
subreddit: "test_subreddit3".to_string(),
|
subreddit: "test_subreddit3".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
"magnet:?xt=urn:btih:test1".to_string(), // Duplicate
|
||||||
|
|
@ -359,7 +361,7 @@ mod tests {
|
||||||
// Create a post with tags
|
// Create a post with tags
|
||||||
let post_info = PostInfo {
|
let post_info = PostInfo {
|
||||||
title: "Test Title with Tags".to_string(),
|
title: "Test Title with Tags".to_string(),
|
||||||
submitter: "test_user".to_string(),
|
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||||
subreddit: "test_subreddit".to_string(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test_tag1".to_string(),
|
"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
|
// Test adding a new magnet with some duplicate and some new tags
|
||||||
let post_info2 = PostInfo {
|
let post_info2 = PostInfo {
|
||||||
title: "Test Title with More Tags".to_string(),
|
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(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
magnet_links: vec!["magnet:?xt=urn:btih:test_tag3".to_string()],
|
magnet_links: vec!["magnet:?xt=urn:btih:test_tag3".to_string()],
|
||||||
timestamp: Utc::now(),
|
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::app::App;
|
||||||
use crate::args::Args;
|
use crate::args::Args;
|
||||||
|
use crate::config::types::{ImdbId, RedditUsername};
|
||||||
use crate::config::{get_db_path, load_config};
|
use crate::config::{get_db_path, load_config};
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use color_eyre::eyre::{Result, WrapErr};
|
use color_eyre::eyre::Result;
|
||||||
use std::fs::create_dir_all;
|
use std::fs::create_dir_all;
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
|
|
@ -12,6 +13,7 @@ mod app;
|
||||||
mod args;
|
mod args;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod errors;
|
||||||
mod magnet;
|
mod magnet;
|
||||||
mod models;
|
mod models;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
|
@ -24,18 +26,18 @@ mod services;
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PostInfo {
|
pub struct PostInfo {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub submitter: String,
|
pub submitter: RedditUsername,
|
||||||
pub magnet_links: Vec<magnet::Magnet>,
|
pub magnet_links: Vec<magnet::Magnet>,
|
||||||
pub subreddit: String,
|
pub subreddit: String,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
pub imdb_id: Option<String>,
|
pub imdb_id: Option<ImdbId>,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Initialize error handling
|
// 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
|
// Parse command-line arguments
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
@ -50,12 +52,17 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
// Create parent directory if it doesn't exist
|
// Create parent directory if it doesn't exist
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
create_dir_all(parent)
|
create_dir_all(parent).map_err(|e| {
|
||||||
.wrap_err_with(|| format!("Failed to create directory: {:?}", parent))?;
|
errors::io_error(e, &format!("Failed to create directory: {:?}", parent))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = Database::new(&db_path)
|
let db = Database::new(&db_path).map_err(|e| {
|
||||||
.wrap_err_with(|| format!("Failed to initialize database at {:?}", db_path))?;
|
errors::db_error(
|
||||||
|
e,
|
||||||
|
&format!("Failed to initialize database at {:?}", db_path),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
let conf = load_config(&args)?;
|
let conf = load_config(&args)?;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::app::Enableable;
|
use crate::app::Enableable;
|
||||||
|
use crate::config::types::{Host, Password, Topic, Username};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Configuration for the ntfy notification service
|
/// Configuration for the ntfy notification service
|
||||||
|
|
@ -9,18 +10,18 @@ pub struct NtfyConfig {
|
||||||
pub enable: bool,
|
pub enable: bool,
|
||||||
|
|
||||||
/// The host URL of the ntfy server
|
/// The host URL of the ntfy server
|
||||||
pub host: String,
|
pub host: Host,
|
||||||
|
|
||||||
/// The username for authentication (optional)
|
/// The username for authentication (optional)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub username: Option<String>,
|
pub username: Option<Username>,
|
||||||
|
|
||||||
/// The password for authentication (optional)
|
/// The password for authentication (optional)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub password: Option<String>,
|
pub password: Option<Password>,
|
||||||
|
|
||||||
/// The topic to publish notifications to
|
/// The topic to publish notifications to
|
||||||
pub topic: String,
|
pub topic: Topic,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Enableable for NtfyConfig {
|
impl Enableable for NtfyConfig {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
pub use config::NtfyConfig;
|
pub use config::NtfyConfig;
|
||||||
pub use notification::NtfyNotification;
|
pub use notification::NtfyNotification;
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,14 @@ pub struct NtfyNotification {
|
||||||
impl NtfyNotification {
|
impl NtfyNotification {
|
||||||
/// Create a new NtfyNotification instance
|
/// Create a new NtfyNotification instance
|
||||||
pub fn new(config: NtfyConfig) -> Result<Self> {
|
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
|
// Add authentication if provided
|
||||||
if let (Some(username), Some(password)) = (&config.username, &config.password) {
|
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()?;
|
let client = builder.build_async()?;
|
||||||
|
|
@ -57,7 +60,7 @@ impl Notification for NtfyNotification {
|
||||||
Priority::Default
|
Priority::Default
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = Payload::new(&self.config.topic)
|
let payload = Payload::new(self.config.topic.to_string())
|
||||||
.message(report::generate_report(
|
.message(report::generate_report(
|
||||||
action_results,
|
action_results,
|
||||||
total_new_links,
|
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 chrono::{DateTime, TimeZone, Utc};
|
||||||
use color_eyre::eyre::{Result, WrapErr};
|
use color_eyre::eyre::{Result, WrapErr};
|
||||||
use roux::util::FeedOption;
|
use roux::util::FeedOption;
|
||||||
|
|
@ -29,10 +30,10 @@ impl RedditClient {
|
||||||
/// * `post_count` - The maximum number of posts to fetch
|
/// * `post_count` - The maximum number of posts to fetch
|
||||||
pub async fn fetch_user_submissions(
|
pub async fn fetch_user_submissions(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &RedditUsername,
|
||||||
post_count: u32,
|
post_count: u32,
|
||||||
) -> Result<Vec<RedditPost>> {
|
) -> Result<Vec<RedditPost>> {
|
||||||
let user = User::new(username);
|
let user = User::new(username.as_str());
|
||||||
let feed_option = FeedOption::new().limit(post_count);
|
let feed_option = FeedOption::new().limit(post_count);
|
||||||
let submissions = user
|
let submissions = user
|
||||||
.submitted(Some(feed_option))
|
.submitted(Some(feed_option))
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ mod tests {
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use color_eyre::eyre::{eyre, Result};
|
use color_eyre::eyre::{eyre, Result};
|
||||||
use std::collections::HashMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
|
@ -198,7 +198,7 @@ mod tests {
|
||||||
bitmagnet: None,
|
bitmagnet: None,
|
||||||
transmission: None,
|
transmission: None,
|
||||||
ntfy: None,
|
ntfy: None,
|
||||||
sources: HashMap::new(),
|
sources: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = action_service.init_actions(&config);
|
let result = action_service.init_actions(&config);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use crate::db::{Database, ProcessedTable};
|
use crate::db::{Database, ProcessedTable};
|
||||||
|
use crate::errors::{self};
|
||||||
use crate::models;
|
use crate::models;
|
||||||
use crate::PostInfo;
|
use crate::PostInfo;
|
||||||
use color_eyre::eyre::{Result, WrapErr};
|
use color_eyre::eyre::Result;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
||||||
/// Service for database operations
|
/// Service for database operations
|
||||||
|
|
@ -31,9 +32,12 @@ impl DatabaseService {
|
||||||
post_info.title
|
post_info.title
|
||||||
);
|
);
|
||||||
|
|
||||||
self.db
|
self.db.store_magnets(post_info).map_err(|e| {
|
||||||
.store_magnets(post_info)
|
errors::db_error(
|
||||||
.wrap_err_with(|| format!("Failed to store magnets for post: {}", post_info.title))
|
e,
|
||||||
|
&format!("Failed to store magnets for post: {}", post_info.title),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all magnet links from the database
|
/// Get all magnet links from the database
|
||||||
|
|
@ -47,7 +51,7 @@ impl DatabaseService {
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.get_all_magnets()
|
.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
|
/// 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>> {
|
pub fn get_tags_for_magnet(&mut self, magnet_id: i32) -> Result<Vec<String>> {
|
||||||
debug!("Retrieving tags for magnet ID: {}", magnet_id);
|
debug!("Retrieving tags for magnet ID: {}", magnet_id);
|
||||||
|
|
||||||
self.db
|
self.db.get_tags_for_magnet(magnet_id).map_err(|e| {
|
||||||
.get_tags_for_magnet(magnet_id)
|
errors::db_error(
|
||||||
.wrap_err_with(|| format!("Failed to retrieve tags for magnet ID: {}", magnet_id))
|
e,
|
||||||
|
&format!("Failed to retrieve tags for magnet ID: {}", magnet_id),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get unprocessed magnet links for a specific action
|
/// Get unprocessed magnet links for a specific action
|
||||||
|
|
@ -77,7 +84,7 @@ impl DatabaseService {
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.get_unprocessed_magnets_for_table::<T>()
|
.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
|
/// Mark a magnet link as processed for a specific action
|
||||||
|
|
@ -90,37 +97,45 @@ impl DatabaseService {
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.mark_magnet_processed_for_table::<T>(magnet_id)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::types::{ImdbId, RedditUsername};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn create_test_post_info() -> PostInfo {
|
fn create_test_post_info() -> PostInfo {
|
||||||
PostInfo {
|
PostInfo {
|
||||||
title: "Test Post".to_string(),
|
title: "Test Post".to_string(),
|
||||||
submitter: "test_user".to_string(),
|
submitter: RedditUsername::try_new("test_user").unwrap(),
|
||||||
subreddit: "test_subreddit".to_string(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
magnet_links: vec![
|
magnet_links: vec![
|
||||||
"magnet:?xt=urn:btih:test1".to_string(),
|
"magnet:?xt=urn:btih:test1".to_string(),
|
||||||
"magnet:?xt=urn:btih:test2".to_string(),
|
"magnet:?xt=urn:btih:test2".to_string(),
|
||||||
],
|
],
|
||||||
timestamp: Utc::now(),
|
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()],
|
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_store_and_retrieve_magnets() -> Result<()> {
|
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_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 mut service = DatabaseService::new(db);
|
||||||
|
|
||||||
let post_info = create_test_post_info();
|
let post_info = create_test_post_info();
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ mod tests {
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use color_eyre::eyre::{eyre, Result};
|
use color_eyre::eyre::{eyre, Result};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -172,7 +173,7 @@ mod tests {
|
||||||
bitmagnet: None,
|
bitmagnet: None,
|
||||||
transmission: None,
|
transmission: None,
|
||||||
ntfy: None,
|
ntfy: None,
|
||||||
sources: HashMap::new(),
|
sources: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = notification_service.init_notifications(&config);
|
let result = notification_service.init_notifications(&config);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ use color_eyre::eyre::{Result, WrapErr};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use regex::Regex;
|
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::cell::RefCell;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
/// Service for processing posts and extracting magnet links
|
/// Service for processing posts and extracting magnet links
|
||||||
|
|
@ -29,8 +34,8 @@ impl PostProcessorService {
|
||||||
/// The number of new magnet links stored
|
/// The number of new magnet links stored
|
||||||
pub fn process_sources(
|
pub fn process_sources(
|
||||||
&mut self,
|
&mut self,
|
||||||
user_posts: &multimap::MultiMap<String, crate::reddit_client::RedditPost>,
|
user_posts: &MultiMap<RedditUsername, RedditPost>,
|
||||||
source_configs: &std::collections::HashMap<String, crate::config::SourceConfig>,
|
source_configs: &BTreeMap<String, SourceConfig>,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let mut total_new_links = 0;
|
let mut total_new_links = 0;
|
||||||
|
|
||||||
|
|
@ -100,12 +105,12 @@ fn filter_post(title: &str, title_filter: Option<Regex>) -> bool {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::types::ImdbId;
|
||||||
use crate::config::SourceConfig;
|
use crate::config::SourceConfig;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::reddit_client::RedditPost;
|
use crate::reddit_client::RedditPost;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use multimap::MultiMap;
|
use multimap::MultiMap;
|
||||||
use std::collections::HashMap;
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -119,7 +124,7 @@ mod tests {
|
||||||
let mut service = PostProcessorService::new(db_service);
|
let mut service = PostProcessorService::new(db_service);
|
||||||
|
|
||||||
let mut user_posts = MultiMap::new();
|
let mut user_posts = MultiMap::new();
|
||||||
let mut source_configs = HashMap::new();
|
let mut source_configs = BTreeMap::new();
|
||||||
|
|
||||||
let post = RedditPost {
|
let post = RedditPost {
|
||||||
title: "Test Post".to_string(),
|
title: "Test Post".to_string(),
|
||||||
|
|
@ -127,17 +132,17 @@ mod tests {
|
||||||
subreddit: "test_subreddit".to_string(),
|
subreddit: "test_subreddit".to_string(),
|
||||||
created: Utc::now(),
|
created: Utc::now(),
|
||||||
};
|
};
|
||||||
user_posts.insert("test_user".to_string(), post);
|
user_posts.insert(RedditUsername::try_new("test_user")?, post);
|
||||||
|
|
||||||
let source_config = SourceConfig {
|
let source_config = SourceConfig {
|
||||||
username: "test_user".to_string(),
|
username: RedditUsername::try_new("test_user")?,
|
||||||
title_filter: None,
|
title_filter: None,
|
||||||
imdb_id: Some("tt1234567".to_string()),
|
imdb_id: Some(ImdbId::try_new("tt1234567")?),
|
||||||
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
tags: vec!["tag1".to_string(), "tag2".to_string()],
|
||||||
};
|
};
|
||||||
source_configs.insert("test_source".to_string(), source_config);
|
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");
|
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 crate::reddit_client::{RedditClient, RedditPost};
|
||||||
use color_eyre::eyre::{Result, WrapErr};
|
use color_eyre::eyre::{Result, WrapErr};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
|
|
@ -56,9 +57,9 @@ impl RedditService {
|
||||||
/// A MultiMap mapping usernames to their posts
|
/// A MultiMap mapping usernames to their posts
|
||||||
pub async fn fetch_posts_from_users(
|
pub async fn fetch_posts_from_users(
|
||||||
&self,
|
&self,
|
||||||
usernames: HashSet<String>,
|
usernames: HashSet<RedditUsername>,
|
||||||
post_count: u32,
|
post_count: u32,
|
||||||
) -> Result<MultiMap<String, RedditPost>> {
|
) -> Result<MultiMap<RedditUsername, RedditPost>> {
|
||||||
let mut user_posts = MultiMap::new();
|
let mut user_posts = MultiMap::new();
|
||||||
|
|
||||||
for username in usernames {
|
for username in usernames {
|
||||||
|
|
@ -93,7 +94,7 @@ impl RedditService {
|
||||||
/// A vector of RedditPost objects
|
/// A vector of RedditPost objects
|
||||||
async fn fetch_user_posts_with_retry(
|
async fn fetch_user_posts_with_retry(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &RedditUsername,
|
||||||
post_count: u32,
|
post_count: u32,
|
||||||
) -> Result<Vec<RedditPost>> {
|
) -> Result<Vec<RedditPost>> {
|
||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,10 @@ impl ReportService {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::actions::action::ProcessedMagnets;
|
|
||||||
use crate::models::Magnet;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_report_service() {
|
fn test_new_report_service() {
|
||||||
let report_service = ReportService::new();
|
let _report_service = ReportService::new();
|
||||||
// Just verify that we can create the service
|
// Just verify that we can create the service
|
||||||
// The actual report generation is tested in the report module
|
// The actual report generation is tested in the report module
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue