diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 3d62bfc8..37e47506 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -44,6 +44,15 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command(about = "Search for a new author to add to Readarr")] + SearchNewAuthor { + #[arg( + long, + help = "The name of the author you want to search for", + required = true + )] + query: String, + }, #[command(about = "Start the specified Readarr task")] StartTask { #[arg( @@ -103,6 +112,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::SearchNewAuthor { query } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::SearchNewAuthor(query).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::StartTask { task_name } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index 00cb2582..c0b25d9e 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -14,6 +14,31 @@ mod tests { assert_err!(&result); } + #[test] + fn test_search_new_author_requires_query() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "search-new-author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_author_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "search-new-author", + "--query", + "test query", + ]); + + assert_ok!(&result); + } + #[test] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); @@ -71,6 +96,33 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_search_new_author_command() { + let expected_query = "test author".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::SearchNewAuthor(expected_query.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let search_new_author_command = ReadarrCommand::SearchNewAuthor { + query: expected_query, + }; + + let result = ReadarrCliHandler::with(&app_arc, search_new_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_start_task_command() { let expected_task_name = ReadarrTaskName::CheckHealth; diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index fb4e85fb..32643d27 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -2,10 +2,12 @@ use anyhow::Result; use log::info; use crate::models::Route; -use crate::models::readarr_models::Author; +use crate::models::readarr_models::{AddAuthorSearchResult, Author}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; +use urlencoding::encode; #[cfg(test)] #[path = "readarr_authors_network_tests.rs"] @@ -55,4 +57,44 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn search_author( + &mut self, + query: String, + ) -> Result> { + info!("Searching for author: {query}"); + let event = ReadarrEvent::SearchNewAuthor(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .await; + + let result = self + .handle_request::<(), Vec>(request_props, |author_vec, mut app| { + if author_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveReadarrBlock::AddAuthorEmptySearchResults.into()); + } else if let Some(add_searched_authors) = + app.data.readarr_data.add_searched_authors.as_mut() + { + add_searched_authors.set_items(author_vec); + } else { + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(author_vec); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + } + }) + .await; + + if result.is_err() { + self.app.lock().await.data.readarr_data.add_searched_authors = Some(StatefulTable::default()); + } + + result + } } diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index eb0ef2cd..e21f0dfe 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{Author, ReadarrSerdeable}; + use crate::models::readarr_models::{AddAuthorSearchResult, Author, ReadarrSerdeable}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; 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::{ - AUTHOR_JSON, stale_author, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, stale_add_author_search_result, stale_author, }; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -187,4 +188,104 @@ mod tests { stale_authors ); } + + #[tokio::test] + async fn test_handle_search_new_author_event() { + let search_results_json = + json!([serde_json::from_str::(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()]); + let expected_results: Vec = + serde_json::from_value(search_results_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(search_results_json) + .query("term=test%20%26%20author") + .build_for(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::AddAuthorSearchResults(search_results) = result.unwrap() else { + panic!("Expected AddAuthorSearchResults") + }; + + assert_eq!(search_results, expected_results); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .items, + expected_results + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_navigates_to_empty_results_when_empty() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_ok!(result); + let app = app.lock().await; + assert_none!(&app.data.readarr_data.add_searched_authors); + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_sets_empty_table_on_api_error() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::( + ADD_AUTHOR_SEARCH_RESULT_JSON + ) + .unwrap()])) + .status(500) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut stale_table = StatefulTable::default(); + stale_table.set_items(vec![stale_add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(stale_table); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_err!(result); + let app = app.lock().await; + assert_some!(&app.data.readarr_data.add_searched_authors); + assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap()); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index f0657670..f665a5bd 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -40,6 +40,7 @@ pub enum ReadarrEvent { GetUpdates, HealthCheck, ListAuthors, + SearchNewAuthor(String), StartTask(ReadarrTaskName), } @@ -47,6 +48,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", + ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -135,6 +137,9 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), + ReadarrEvent::SearchNewAuthor(query) => { + self.search_author(query).await.map(ReadarrSerdeable::from) + } ReadarrEvent::StartTask(task_name) => self .start_readarr_task(task_name) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 4ecf5bd4..90578f88 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -1,7 +1,9 @@ #[cfg(test)] #[allow(dead_code)] pub mod test_utils { - use crate::models::readarr_models::{Author, AuthorStatus, NewItemMonitorType}; + use crate::models::readarr_models::{ + AddAuthorSearchResult, Author, AuthorStatus, NewItemMonitorType, + }; use crate::models::servarr_models::RootFolder; pub const AUTHOR_JSON: &str = r#"{ @@ -31,6 +33,18 @@ pub mod test_utils { } }"#; + pub const ADD_AUTHOR_SEARCH_RESULT_JSON: &str = r#"{ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "continuing", + "ended": false, + "overview": "some interesting description of the author", + "authorType": "Person", + "disambiguation": "American novelist", + "genres": ["science fiction"], + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -49,6 +63,15 @@ pub mod test_utils { } } + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { + AddAuthorSearchResult { + foreign_author_id: "foreign-author-99".to_owned(), + author_name: "Stale Author".into(), + status: AuthorStatus::Continuing, + ..AddAuthorSearchResult::default() + } + } + pub fn stale_root_folder() -> RootFolder { RootFolder { id: 99, diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d35eb305..a392a3df 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -15,6 +15,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] + #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")]