diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs index 0e8c808d..d6cdb540 100644 --- a/src/cli/readarr/edit_command_handler.rs +++ b/src/cli/readarr/edit_command_handler.rs @@ -5,6 +5,7 @@ use clap::{ArgAction, ArgGroup, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; +use crate::models::servarr_models::EditIndexerParams; use crate::{ app::App, cli::{CliCommandHandler, Command, mutex_flags_or_option}, @@ -79,6 +80,97 @@ pub enum ReadarrEditCommand { #[arg(long, help = "Clear all tags on this author", conflicts_with = "tag")] clear_tags: bool, }, + #[command( + about = "Edit preferences for the specified indexer", + group( + ArgGroup::new("edit_indexer") + .args([ + "name", + "enable_rss", + "disable_rss", + "enable_automatic_search", + "disable_automatic_search", + "enable_interactive_search", + "disable_interactive_search", + "url", + "api_key", + "seed_ratio", + "tag", + "priority", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Indexer { + #[arg( + long, + help = "The ID of the indexer whose settings you wish to edit", + required = true + )] + indexer_id: i64, + #[arg(long, help = "The name of the indexer")] + name: Option, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "disable_rss" + )] + enable_rss: bool, + #[arg( + long, + help = "Disable using this indexer when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "enable_rss" + )] + disable_rss: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when automatic searches are performed via the UI or by Readarr", + conflicts_with = "disable_automatic_search" + )] + enable_automatic_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever automatic searches are performed via the UI or by Readarr", + conflicts_with = "enable_automatic_search" + )] + disable_automatic_search: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when an interactive search is used", + conflicts_with = "disable_interactive_search" + )] + enable_interactive_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever an interactive search is performed", + conflicts_with = "enable_interactive_search" + )] + disable_interactive_search: bool, + #[arg(long, help = "The URL of the indexer")] + url: Option, + #[arg(long, help = "The API key used to access the indexer's API")] + api_key: Option, + #[arg( + long, + help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules" + )] + seed_ratio: Option, + #[arg( + long, + help = "Only use this indexer for authors with at least one matching tag ID. Leave blank to use with all authors.", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg( + long, + help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Readarr will still use all enabled indexers for RSS Sync and Searching" + )] + priority: Option, + #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] + clear_tags: bool, + }, } impl From for Command { @@ -138,6 +230,48 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditComman .await?; "Author updated".to_owned() } + ReadarrEditCommand::Indexer { + indexer_id, + name, + enable_rss, + disable_rss, + enable_automatic_search, + disable_automatic_search, + enable_interactive_search, + disable_interactive_search, + url, + api_key, + seed_ratio, + tag, + priority, + clear_tags, + } => { + let rss_value = mutex_flags_or_option(enable_rss, disable_rss); + let automatic_search_value = + mutex_flags_or_option(enable_automatic_search, disable_automatic_search); + let interactive_search_value = + mutex_flags_or_option(enable_interactive_search, disable_interactive_search); + let edit_indexer_params = EditIndexerParams { + indexer_id, + name, + enable_rss: rss_value, + enable_automatic_search: automatic_search_value, + enable_interactive_search: interactive_search_value, + url, + api_key, + seed_ratio, + tags: tag, + tag_input_string: None, + priority, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditIndexer(edit_indexer_params).into()) + .await?; + "Indexer updated".to_owned() + } }; Ok(result) diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs index 80313dcb..3d15d004 100644 --- a/src/cli/readarr/edit_command_handler_tests.rs +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -272,6 +272,282 @@ mod tests { }; assert_eq!(edit_command, expected_args); } + + #[test] + fn test_edit_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_with_indexer_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_rss_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-rss", + "--disable-rss", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_automatic_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-automatic-search", + "--disable-automatic-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_interactive_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-interactive-search", + "--disable-interactive-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_indexer_assert_argument_flags_require_args( + #[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[rstest] + fn test_edit_indexer_every_boolean_flag_satisfies_the_argument_group( + #[values( + "--enable-rss", + "--disable-rss", + "--enable-automatic-search", + "--disable-automatic-search", + "--enable-interactive-search", + "--disable-interactive-search", + "--clear-tags" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_ok!(&result); + } + + #[test] + fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: None, + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + ]); + + 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_indexer_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: None, + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: Some(vec![2, 3]), + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "2", + "--tag", + "3", + ]); + + 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_indexer_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + "--enable-rss", + "--enable-automatic-search", + "--enable-interactive-search", + "--url", + "http://test.com", + "--api-key", + "testKey", + "--seed-ratio", + "1.2", + "--tag", + "2", + "--tag", + "3", + "--priority", + "25", + ]); + + 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); + } } mod handler { @@ -287,6 +563,7 @@ mod tests { }; use crate::models::Serdeable; use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; + use crate::models::servarr_models::EditIndexerParams; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, @@ -476,5 +753,59 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_edit_indexer_command() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: Some(true), + enable_automatic_search: Some(false), + enable_interactive_search: Some(true), + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tags: Some(vec![2, 3]), + tag_input_string: None, + priority: Some(25), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditIndexer(expected_edit_indexer_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_indexer_command = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: true, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 3b9b8db5..d374a34f 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -1,14 +1,230 @@ -use crate::models::servarr_models::Indexer; +use crate::models::servarr_models::{EditIndexerParams, Indexer}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; -use anyhow::Result; -use log::info; +use anyhow::{Context, Result}; +use log::{debug, info}; +use serde_json::{Value, json}; #[cfg(test)] #[path = "readarr_indexers_network_tests.rs"] mod readarr_indexers_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn edit_readarr_indexer( + &mut self, + mut edit_indexer_params: EditIndexerParams, + ) -> Result<()> { + if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_indexer_params.tags = Some(tag_ids_vec); + } + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::EditIndexer(EditIndexerParams::default()); + let id = edit_indexer_params.indexer_id; + info!("Updating Readarr indexer with ID: {id}"); + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = { + let priority = detailed_indexer_body["priority"] + .as_i64() + .context("Failed to deserialize indexer 'priority' field")?; + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array")? + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = edit_indexer_params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .context("Failed to deserialize indexer 'name' field")? + .to_owned(), + ); + let enable_rss = edit_indexer_params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .context("Failed to deserialize indexer 'enableRss' field")?, + ); + let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableAutomaticSearch' field")?, + ); + let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableInteractiveSearch' field")?, + ); + let url = edit_indexer_params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for baseUrl")? + .iter() + .find(|field| field["name"] == "baseUrl") + .context("Field 'baseUrl' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'baseUrl' value")? + .to_owned(), + ); + let api_key = edit_indexer_params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for apiKey")? + .iter() + .find(|field| field["name"] == "apiKey") + .context("Field 'apiKey' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'apiKey' value")? + .to_owned(), + ); + let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .unwrap_or("") + .to_owned(); + } + + String::new() + }); + let tags = if edit_indexer_params.clear_tags { + vec![] + } else { + edit_indexer_params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .context("Failed to get indexer 'tags' array")? + .iter() + .map(|item| { + item + .as_i64() + .context("Failed to deserialize indexer tag ID") + }) + .collect::>>()?, + ) + }; + let priority = edit_indexer_params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + }; + + *detailed_indexer_body + .get_mut("name") + .context("Failed to get mutable reference to indexer 'name' field")? = json!(name); + *detailed_indexer_body + .get_mut("priority") + .context("Failed to get mutable reference to indexer 'priority' field")? = json!(priority); + *detailed_indexer_body + .get_mut("enableRss") + .context("Failed to get mutable reference to indexer 'enableRss' field")? = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .context("Failed to get mutable reference to indexer 'enableAutomaticSearch' field")? = + json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .context("Failed to get mutable reference to indexer 'enableInteractiveSearch' field")? = + json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array")? + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .context("Failed to find 'baseUrl' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'baseUrl' value")? = json!(url); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for apiKey")? + .iter_mut() + .find(|field| field["name"] == "apiKey") + .context("Failed to find 'apiKey' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'apiKey' value")? = json!(api_key); + *detailed_indexer_body + .get_mut("tags") + .context("Failed to get mutable reference to indexer 'tags' field")? = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for seed ratio")? + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .context("Failed to get mutable reference to 'seedCriteria.seedRatio' object")? + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_indexers( &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 18f7016a..e09db0f3 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -1,12 +1,15 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::Indexer; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ INDEXER_JSON, indexer, stale_indexer, }; + use bimap::BiMap; + use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -78,4 +81,313 @@ mod tests { stale_indexers ); } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("name").unwrap() = json!("Test Update"); + *expected_body.get_mut("priority").unwrap() = json!(3); + *expected_body.get_mut("enableRss").unwrap() = json!(false); + *expected_body.get_mut("enableAutomaticSearch").unwrap() = json!(false); + *expected_body.get_mut("enableInteractiveSearch").unwrap() = json!(false); + *expected_body["fields"][0].get_mut("value").unwrap() = json!("https://localhost:9696/8/"); + *expected_body["fields"][1].get_mut("value").unwrap() = json!("test1234"); + *expected_body["fields"][2].get_mut("value").unwrap() = json!("1.3"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/8/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(3), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([2, 3]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + tags: Some(vec![2, 3]), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_clears_tags_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + clear_tags: true, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_absent_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"].as_array_mut().unwrap().pop(); + let expected_body = details_body.clone(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_populates_the_seed_ratio_value_when_it_is_missing_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"][2] + .as_object_mut() + .unwrap() + .remove("value"); + let mut expected_body = details_body.clone(); + expected_body["fields"][2] + .as_object_mut() + .unwrap() + .insert("value".to_owned(), json!("1.3")); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_failure() { + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(500) + .match_header("X-Api-Key", "test1234") + .with_body(INDEXER_JSON) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 91ce827a..bb6d179f 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -7,7 +7,9 @@ use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, }; -use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; +use crate::models::servarr_models::{ + EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, +}; use crate::network::{Network, RequestMethod}; mod blocklist; @@ -41,6 +43,7 @@ pub enum ReadarrEvent { DeleteTag(i64), DownloadRelease(ReleaseDownloadBody), EditAuthor(EditAuthorParams), + EditIndexer(EditIndexerParams), GetAuthorDetails(i64), GetAuthorHistory(i64), GetAuthorReleases(i64), @@ -112,7 +115,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - ReadarrEvent::GetIndexers => "/indexer", + ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => "/indexer", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -189,6 +192,10 @@ impl Network<'_, '_> { .edit_author(edit_author_params) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditIndexer(edit_indexer_params) => self + .edit_readarr_indexer(edit_indexer_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetAuthorDetails(author_id) => self .get_author_details(author_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index b95342b5..620d4abd 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -159,9 +159,33 @@ pub mod test_utils { "configContract": "TorznabSettings", "infoLink": "https://wiki.servarr.com/readarr/supported#torznab", "fields": [ - { "name": "baseUrl", "value": "https://test.com" }, - { "name": "apiKey", "value": "" }, - { "name": "seedCriteria.seedRatio", "value": "1.2" } + { + "order": 0, + "name": "baseUrl", + "label": "URL", + "value": "https://test.com", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 2, + "name": "apiKey", + "label": "API Key", + "value": "", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 7, + "name": "seedCriteria.seedRatio", + "label": "Seed Ratio", + "value": "1.2", + "type": "number", + "advanced": false, + "isFloat": true + } ], "tags": [1], "id": 8 diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 16ef7bad..3142fcef 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -4,7 +4,9 @@ mod tests { use crate::models::readarr_models::{ AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, }; - use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; + use crate::models::servarr_models::{ + EditIndexerParams, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; use bimap::BiMap; @@ -47,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] + #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")]