From dbd84d929d1b1d845454e29ce36fe90a2d8c82d6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 15:37:25 -0600 Subject: [PATCH] feat(readarr): edit the shared indexer settings and expose the command on the CLI --- src/cli/readarr/edit_command_handler.rs | 67 ++++- src/cli/readarr/edit_command_handler_tests.rs | 246 +++++++++++++++++- src/network/readarr_network/indexers/mod.rs | 17 ++ .../readarr_indexers_network_tests.rs | 36 +++ src/network/readarr_network/mod.rs | 11 +- .../readarr_network/readarr_network_tests.rs | 6 +- 6 files changed, 378 insertions(+), 5 deletions(-) diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs index d6cdb540..3af38b2f 100644 --- a/src/cli/readarr/edit_command_handler.rs +++ b/src/cli/readarr/edit_command_handler.rs @@ -5,7 +5,9 @@ use clap::{ArgAction, ArgGroup, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; -use crate::models::servarr_models::EditIndexerParams; +use crate::models::Serdeable; +use crate::models::readarr_models::ReadarrSerdeable; +use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; use crate::{ app::App, cli::{CliCommandHandler, Command, mutex_flags_or_option}, @@ -19,6 +21,40 @@ mod edit_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrEditCommand { + #[command( + about = "Edit the indexer settings that apply to all indexers", + group( + ArgGroup::new("edit_settings") + .args([ + "maximum_size", + "minimum_age", + "retention", + "rss_sync_interval", + ]).required(true) + .multiple(true)) + )] + AllIndexerSettings { + #[arg( + long, + help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited" + )] + maximum_size: Option, + #[arg( + long, + help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider." + )] + minimum_age: Option, + #[arg( + long, + help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention" + )] + retention: Option, + #[arg( + long, + help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)" + )] + rss_sync_interval: Option, + }, #[command( about = "Edit preferences for the specified author", group( @@ -200,6 +236,35 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditComman async fn handle(self) -> Result { let result = match self.command { + ReadarrEditCommand::AllIndexerSettings { + maximum_size, + minimum_age, + retention, + rss_sync_interval, + } => { + if let Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(previous_indexer_settings)) = + self + .network + .handle_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await? + { + let params = IndexerSettings { + id: 1, + maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size), + minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age), + retention: retention.unwrap_or(previous_indexer_settings.retention), + rss_sync_interval: rss_sync_interval + .unwrap_or(previous_indexer_settings.rss_sync_interval), + }; + self + .network + .handle_network_event(ReadarrEvent::EditAllIndexerSettings(params).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() + } + } ReadarrEditCommand::Author { author_id, enable_monitoring, diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs index 3d15d004..34da2e65 100644 --- a/src/cli/readarr/edit_command_handler_tests.rs +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -36,6 +36,102 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; + #[test] + fn test_edit_all_indexer_settings_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_edit_all_indexer_settings_assert_argument_flags_require_args( + #[values( + "--maximum-size", + "--minimum-age", + "--retention", + "--rss-sync-interval" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_all_indexer_settings_all_arguments_defined() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + "--minimum-age", + "17", + "--retention", + "43", + "--rss-sync-interval", + "35", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + #[test] fn test_edit_author_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "author"]); @@ -554,6 +650,7 @@ mod tests { use std::sync::Arc; use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; use serde_json::json; use tokio::sync::Mutex; @@ -563,13 +660,160 @@ mod tests { }; use crate::models::Serdeable; use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 17, + retention: 43, + rss_sync_interval: 35, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_defaults_to_previous_values() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_does_not_edit_on_unexpected_response() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_str_eq!(assert_ok!(result), String::new()); + } + #[tokio::test] async fn test_handle_edit_author_command() { let expected_edit_author_params = EditAuthorParams { diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index db0bde54..b7413dc0 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -36,6 +36,23 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn edit_all_readarr_indexer_settings( + &mut self, + params: IndexerSettings, + ) -> Result { + info!("Updating Readarr indexer settings"); + let event = ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()); + debug!("Indexer settings body: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| {}) + .await + } + pub(in crate::network::readarr_network) async fn get_all_readarr_indexer_settings( &mut self, ) -> Result { diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index c9f5920d..02dbcbf1 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -714,4 +714,40 @@ mod tests { let app = app.lock().await; assert_none!(&app.data.readarr_data.indexer_settings); } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event_failure() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 551dd219..48d1a6f5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -8,7 +8,7 @@ use crate::models::readarr_models::{ ReadarrTaskName, }; use crate::models::servarr_models::{ - EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; use crate::network::{Network, RequestMethod}; @@ -43,6 +43,7 @@ pub enum ReadarrEvent { DeleteRootFolder(i64), DeleteTag(i64), DownloadRelease(ReleaseDownloadBody), + EditAllIndexerSettings(IndexerSettings), EditAuthor(EditAuthorParams), EditIndexer(EditIndexerParams), GetAllIndexerSettings, @@ -113,7 +114,9 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::UpdateAndScanAuthor(_) | ReadarrEvent::UpdateDownloads => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", - ReadarrEvent::GetAllIndexerSettings => "/config/indexer", + ReadarrEvent::GetAllIndexerSettings | ReadarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", @@ -201,6 +204,10 @@ impl Network<'_, '_> { .download_readarr_release(release_download_body) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditAllIndexerSettings(params) => self + .edit_all_readarr_indexer_settings(params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::EditAuthor(edit_author_params) => self .edit_author(edit_author_params) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 2b6bfa1b..1c0bf4a3 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -5,7 +5,7 @@ mod tests { AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; use crate::models::servarr_models::{ - EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -43,6 +43,10 @@ mod tests { #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetAllIndexerSettings, "/config/indexer")] + #[case( + ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()), + "/config/indexer" + )] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")]