From 243de47cae5800b50479806d838a123c0f66d144 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 15:53:18 -0700 Subject: [PATCH] feat: Initial Lidarr support for searching for new artists --- src/app/lidarr/lidarr_context_clues.rs | 17 +- src/app/lidarr/lidarr_context_clues_tests.rs | 51 ++- src/app/lidarr/lidarr_tests.rs | 31 +- src/app/lidarr/mod.rs | 18 + src/cli/lidarr/lidarr_command_tests.rs | 51 +++ src/cli/lidarr/mod.rs | 16 + .../library/add_artist_handler.rs | 181 +++++++++ .../library/add_artist_handler_tests.rs | 356 ++++++++++++++++++ .../library/library_handler_tests.rs | 34 +- src/handlers/lidarr_handlers/library/mod.rs | 18 +- src/models/lidarr_models.rs | 14 + src/models/lidarr_models_tests.rs | 72 +++- src/models/servarr_data/lidarr/lidarr_data.rs | 24 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 12 +- .../library/lidarr_library_network_tests.rs | 87 ++++- src/network/lidarr_network/library/mod.rs | 46 ++- .../lidarr_network_test_utils.rs | 31 +- src/network/lidarr_network/mod.rs | 5 + src/network/radarr_network/indexers/mod.rs | 10 +- .../indexers/radarr_indexers_network_tests.rs | 8 +- .../library/radarr_library_network_tests.rs | 23 +- src/network/sonarr_network/indexers/mod.rs | 8 +- .../indexers/sonarr_indexers_network_tests.rs | 8 +- .../series/sonarr_series_network_tests.rs | 19 +- src/ui/lidarr_ui/library/add_artist_ui.rs | 151 ++++++++ .../lidarr_ui/library/add_artist_ui_tests.rs | 61 +++ src/ui/lidarr_ui/library/library_ui_tests.rs | 3 +- src/ui/lidarr_ui/library/mod.rs | 6 +- ...ot_tests__AddArtistEmptySearchResults.snap | 47 +++ ..._snapshot_tests__AddArtistSearchInput.snap | 47 +++ ...napshot_tests__AddArtistSearchResults.snap | 47 +++ ...artist_ui_AddArtistEmptySearchResults.snap | 47 +++ ...s__add_artist_ui_AddArtistSearchInput.snap | 47 +++ ..._add_artist_ui_AddArtistSearchResults.snap | 47 +++ ..._artist_ui_renders_loading_for_search.snap | 47 +++ .../indexers/test_all_indexers_ui.rs | 14 +- .../indexers/test_all_indexers_ui.rs | 14 +- 37 files changed, 1646 insertions(+), 72 deletions(-) create mode 100644 src/handlers/lidarr_handlers/library/add_artist_handler.rs create mode 100644 src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs create mode 100644 src/ui/lidarr_ui/library/add_artist_ui.rs create mode 100644 src/ui/lidarr_ui/library/add_artist_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index b33782b..ff3409c 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -1,13 +1,15 @@ use crate::app::App; -use crate::app::context_clues::{ContextClue, ContextClueProvider}; +use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, ContextClue, ContextClueProvider}; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; #[cfg(test)] #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 9] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 10] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), ( DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc, @@ -25,6 +27,11 @@ pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 9] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; +pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + pub(in crate::app) struct LidarrContextClueProvider; impl ContextClueProvider for LidarrContextClueProvider { @@ -34,6 +41,12 @@ impl ContextClueProvider for LidarrContextClueProvider { }; match active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults => { + Some(&BARE_POPUP_CONTEXT_CLUES) + } + _ if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) => { + Some(&ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES) + } _ => app .data .lidarr_data diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 5348871..a7cf4ca 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -1,18 +1,23 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::app::context_clues::ContextClueProvider; + use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, ContextClueProvider}; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::lidarr::lidarr_context_clues::{ - ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, + ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, }; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use rstest::rstest; #[test] fn test_artists_context_clues() { let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter(); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc) + ); assert_some_eq_x!( artists_context_clues_iter.next(), &( @@ -58,6 +63,22 @@ mod tests { assert_none!(artists_context_clues_iter.next()); } + #[test] + fn test_add_artist_search_results_context_clues() { + let mut add_artist_search_results_context_clues_iter = + ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + add_artist_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + add_artist_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "edit search") + ); + assert_none!(add_artist_search_results_context_clues_iter.next()); + } + #[test] #[should_panic( expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route" @@ -108,4 +129,30 @@ mod tests { assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); } + + #[rstest] + fn test_lidarr_context_clue_provider_bare_popup_context_clues( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_add_artist_search_results_context_clues() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES); + } } diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 872cdd9..9007ac4 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkEvent; use crate::network::lidarr_network::LidarrEvent; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use tokio::sync::mpsc; #[tokio::test] @@ -31,4 +31,33 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); } + + #[tokio::test] + async fn test_dispatch_by_lidarr_block_add_artist_search_results() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.network_tx = Some(tx); + app.data.lidarr_data.add_artist_search = Some("test artist".into()); + + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::AddArtistSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::SearchNewArtist("test artist".to_owned()).into() + ); + assert!(!app.data.lidarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_extract_add_new_artist_search_query() { + let app = App::test_default_fully_populated(); + + let query = app.extract_add_new_artist_search_query().await; + + assert_str_eq!(query, "Test Artist"); + } } diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index 716376f..7c5b118 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -28,6 +28,13 @@ impl App<'_> { .dispatch_network_event(LidarrEvent::ListArtists.into()) .await; } + ActiveLidarrBlock::AddArtistSearchResults => { + self + .dispatch_network_event( + LidarrEvent::SearchNewArtist(self.extract_add_new_artist_search_query().await).into(), + ) + .await; + } _ => (), } @@ -35,6 +42,17 @@ impl App<'_> { self.reset_tick_count(); } + async fn extract_add_new_artist_search_query(&self) -> String { + self + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search should be set") + .text + .clone() + } + async fn check_for_lidarr_prompt_action(&mut self) { if self.data.lidarr_data.prompt_confirm { self.data.lidarr_data.prompt_confirm = false; diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 4dbafb1..59c37d9 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -74,6 +74,30 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_search_new_artist_requires_query() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_artist_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "search-new-artist", + "--query", + "test query", + ]); + + assert_ok!(&result); + } } mod handler { @@ -255,5 +279,32 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_search_new_artist_command() { + let expected_query = "test artist".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::SearchNewArtist(expected_query.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let search_new_artist_command = LidarrCommand::SearchNewArtist { + query: expected_query, + }; + + let result = LidarrCliHandler::with(&app_arc, search_new_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 306051b..29bc93f 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -58,6 +58,15 @@ pub enum LidarrCommand { about = "Commands to refresh the data in your Lidarr instance" )] Refresh(LidarrRefreshCommand), + #[command(about = "Search for a new artist to add to Lidarr")] + SearchNewArtist { + #[arg( + long, + help = "The name of the artist you want to search for", + required = true + )] + query: String, + }, #[command( about = "Toggle monitoring for the specified artist corresponding to the given artist ID" )] @@ -128,6 +137,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::SearchNewArtist { query } => { + let resp = self + .network + .handle_network_event(LidarrEvent::SearchNewArtist(query).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrCommand::ToggleArtistMonitoring { artist_id } => { let resp = self .network diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler.rs b/src/handlers/lidarr_handlers/library/add_artist_handler.rs new file mode 100644 index 0000000..39b8fae --- /dev/null +++ b/src/handlers/lidarr_handlers/library/add_artist_handler.rs @@ -0,0 +1,181 @@ +use crate::handlers::KeyEventHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; +use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys}; + +#[cfg(test)] +#[path = "add_artist_handler_tests.rs"] +mod add_artist_handler_tests; + +pub struct AddArtistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddArtistHandler<'a, 'b> { + fn handle(&mut self) { + let add_artist_table_handling_config = + TableHandlingConfig::new(ActiveLidarrBlock::AddArtistSearchResults.into()); + + if !handle_table( + self, + |app| { + app + .data + .lidarr_data + .add_searched_artists + .as_mut() + .expect("add_searched_artists should be initialized") + }, + add_artist_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + ADD_ARTIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> AddArtistHandler<'a, 'b> { + AddArtistHandler { + key, + app, + active_lidarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + .scroll_home(); + } + } + + fn handle_end(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + .reset_offset(); + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + ) + } + } + + fn handle_submit(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + let search_text = &self + .app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text; + + if !search_text.is_empty() { + self + .app + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + self.app.ignore_special_keys_for_textbox_input = false; + } + } + ActiveLidarrBlock::AddArtistSearchResults => {} + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.add_artist_search = None; + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveLidarrBlock::AddArtistSearchResults + | ActiveLidarrBlock::AddArtistEmptySearchResults => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.add_searched_artists = None; + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + ) + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs new file mode 100644 index 0000000..dba8ca9 --- /dev/null +++ b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs @@ -0,0 +1,356 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::add_artist_handler::AddArtistHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; + use crate::models::stateful_table::StatefulTable; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::add_artist_search_result; + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_artist_search_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_artist_search_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use super::*; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_artist_search_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.lidarr_data.add_artist_search = Some("test".into()); + + AddArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistSearchResults.into() + ); + } + + #[test] + fn test_add_artist_search_input_submit_noop_on_empty_search() { + let mut app = App::test_default(); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistSearchInput.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_modal_absent; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_artist_search_input_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.lidarr_data.add_artist_search = Some("test".into()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + + AddArtistHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_modal_absent!(app.data.lidarr_data.add_artist_search); + } + + #[rstest] + fn test_add_artist_search_results_esc( + #[values( + ActiveLidarrBlock::AddArtistSearchResults, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.push_navigation_stack(active_lidarr_block.into()); + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(vec![add_artist_search_result()]); + app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); + + AddArtistHandler::new(ESC_KEY, &mut app, active_lidarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::AddArtistSearchInput.into()); + assert_modal_absent!(app.data.lidarr_data.add_searched_artists); + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + mod test_handle_key_char { + use super::*; + + #[test] + fn test_add_artist_search_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text, + "Tes" + ); + } + + #[test] + fn test_add_artist_search_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + + AddArtistHandler::new( + Key::Char('a'), + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text, + "a" + ); + } + } + + #[test] + fn test_add_artist_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(AddArtistHandler::accepts(active_lidarr_block)); + } else { + assert!(!AddArtistHandler::accepts(active_lidarr_block)); + } + }); + } + + #[rstest] + fn test_add_artist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_add_artist_search_no_panic_on_none_search_result() { + let mut app = App::test_default(); + app.data.lidarr_data.add_searched_artists = None; + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchResults, + None, + ) + .handle(); + } + + #[test] + fn test_add_artist_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_artist_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index 7678f34..485ee90 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -13,8 +13,8 @@ mod tests { use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, - LIBRARY_BLOCKS, + ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::network::lidarr_network::LidarrEvent; @@ -28,6 +28,7 @@ mod tests { library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); + library_handler_blocks.extend(ADD_ARTIST_BLOCKS); ActiveLidarrBlock::iter().for_each(|lidarr_block| { if library_handler_blocks.contains(&lidarr_block) { @@ -502,6 +503,35 @@ mod tests { ] } + #[rstest] + fn test_delegates_add_artist_blocks_to_add_artist_handler( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistEmptySearchResults, + ActiveLidarrBlock::AddArtistSearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_lidarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + #[test] fn test_delegates_delete_artist_blocks_to_delete_artist_handler() { let mut app = App::test_default(); diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index fdbf9c2..9bee4f3 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -4,7 +4,7 @@ use crate::{ handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, matches_key, models::{ - BlockSelectionState, + BlockSelectionState, HorizontallyScrollableText, lidarr_models::Artist, servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, @@ -18,10 +18,12 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +mod add_artist_handler; mod delete_artist_handler; mod edit_artist_handler; use crate::models::Route; +pub(in crate::handlers::lidarr_handlers) use add_artist_handler::AddArtistHandler; pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler; @@ -60,6 +62,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' artists_table_handling_config, ) { match self.active_lidarr_block { + _ if AddArtistHandler::accepts(self.active_lidarr_block) => { + AddArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) + .handle(); + } _ if DeleteArtistHandler::accepts(self.active_lidarr_block) => { DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) .handle(); @@ -74,7 +80,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' } fn accepts(active_block: ActiveLidarrBlock) -> bool { - DeleteArtistHandler::accepts(active_block) + AddArtistHandler::accepts(active_block) + || DeleteArtistHandler::accepts(active_block) || EditArtistHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } @@ -157,6 +164,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' let key = self.key; match self.active_lidarr_block { ActiveLidarrBlock::Artists => match key { + _ if matches_key!(add, key) => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + self.app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + self.app.ignore_special_keys_for_textbox_input = true; + } _ if matches_key!(toggle_monitoring, key) => { self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm_action = Some( diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 56278b0..21046ab 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -198,6 +198,19 @@ pub struct SystemStatus { pub start_time: DateTime, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddArtistSearchResult { + pub foreign_artist_id: String, + pub artist_name: HorizontallyScrollableText, + pub status: ArtistStatus, + pub overview: Option, + pub artist_type: Option, + pub disambiguation: Option, + pub genres: Vec, + pub ratings: Option, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub struct DeleteArtistParams { @@ -229,6 +242,7 @@ impl From for Serdeable { serde_enum_from!( LidarrSerdeable { + AddArtistSearchResults(Vec), Artist(Artist), Artists(Vec), DiskSpaces(Vec), diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index bb3f478..579f817 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, - SystemStatus, + AddArtistSearchResult, DownloadRecord, DownloadStatus, DownloadsResponse, Member, + MetadataProfile, NewItemMonitorType, SystemStatus, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag, @@ -424,4 +424,72 @@ mod tests { ); assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); } + + #[test] + fn test_add_artist_search_result_deserialization() { + let search_result_json = json!({ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "continuing", + "overview": "Test overview", + "artistType": "Group", + "disambiguation": "UK Band", + "genres": ["Rock", "Alternative"], + "ratings": { + "votes": 100, + "value": 4.5 + } + }); + + let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id"); + assert_str_eq!(search_result.artist_name.text, "Test Artist"); + assert_eq!(search_result.status, ArtistStatus::Continuing); + assert_some_eq_x!(&search_result.overview, "Test overview"); + assert_some_eq_x!(&search_result.artist_type, "Group"); + assert_some_eq_x!(&search_result.disambiguation, "UK Band"); + assert_eq!(search_result.genres, vec!["Rock", "Alternative"]); + assert_some!(&search_result.ratings); + + let ratings = search_result.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + } + + #[test] + fn test_add_artist_search_result_with_optional_fields_none() { + let search_result_json = json!({ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "ended", + "genres": [] + }); + + let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id"); + assert_str_eq!(search_result.artist_name.text, "Test Artist"); + assert_eq!(search_result.status, ArtistStatus::Ended); + assert_none!(&search_result.overview); + assert_none!(&search_result.artist_type); + assert_none!(&search_result.disambiguation); + assert!(search_result.genres.is_empty()); + assert_none!(&search_result.ratings); + } + + #[test] + fn test_lidarr_serdeable_from_add_artist_search_results() { + let search_results = vec![AddArtistSearchResult { + foreign_artist_id: "test-id".to_owned(), + ..AddArtistSearchResult::default() + }]; + + let lidarr_serdeable: LidarrSerdeable = search_results.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::AddArtistSearchResults(search_results) + ); + } } diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 240f68d..14b4753 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -3,8 +3,8 @@ use serde_json::Number; use super::modals::EditArtistModal; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ - BlockSelectionState, Route, TabRoute, TabState, - lidarr_models::{Artist, DownloadRecord}, + BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState, + lidarr_models::{AddArtistSearchResult, Artist, DownloadRecord}, servarr_models::{DiskSpace, RootFolder}, stateful_table::StatefulTable, }; @@ -18,7 +18,8 @@ use { crate::models::stateful_table::SortOption, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ - download_record, metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map, + add_artist_search_result, download_record, metadata_profile, metadata_profile_map, + quality_profile, root_folder, tags_map, }, crate::network::servarr_test_utils::diskspace, strum::{Display, EnumString, IntoEnumIterator}, @@ -29,7 +30,9 @@ use { mod lidarr_data_tests; pub struct LidarrData<'a> { + pub add_artist_search: Option, pub add_import_list_exclusion: bool, + pub add_searched_artists: Option>, pub artists: StatefulTable, pub delete_artist_files: bool, pub disk_space_vec: Vec, @@ -82,7 +85,9 @@ impl LidarrData<'_> { impl<'a> Default for LidarrData<'a> { fn default() -> LidarrData<'a> { LidarrData { + add_artist_search: None, add_import_list_exclusion: false, + add_searched_artists: None, artists: StatefulTable::default(), delete_artist_files: false, disk_space_vec: Vec::new(), @@ -145,6 +150,10 @@ impl LidarrData<'_> { lidarr_data.downloads.set_items(vec![download_record()]); lidarr_data.root_folders.set_items(vec![root_folder()]); lidarr_data.version = "1.0.0".to_owned(); + lidarr_data.add_artist_search = Some("Test Artist".into()); + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(vec![add_artist_search_result()]); + lidarr_data.add_searched_artists = Some(add_searched_artists); lidarr_data } @@ -156,6 +165,9 @@ pub enum ActiveLidarrBlock { #[default] Artists, ArtistsSortPrompt, + AddArtistEmptySearchResults, + AddArtistSearchInput, + AddArtistSearchResults, DeleteArtistPrompt, DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, @@ -185,6 +197,12 @@ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [ ActiveLidarrBlock::UpdateAllArtistsPrompt, ]; +pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 3] = [ + ActiveLidarrBlock::AddArtistEmptySearchResults, + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistSearchResults, +]; + pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [ ActiveLidarrBlock::DeleteArtistPrompt, ActiveLidarrBlock::DeleteArtistConfirmPrompt, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index ceb3417..41e5c8c 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::servarr_data::lidarr::lidarr_data::{ - DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, + ADD_ARTIST_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, }; use crate::models::{ @@ -109,7 +109,9 @@ mod tests { fn test_lidarr_data_default() { let lidarr_data = LidarrData::default(); + assert_none!(lidarr_data.add_artist_search); assert!(!lidarr_data.add_import_list_exclusion); + assert_none!(lidarr_data.add_searched_artists); assert_is_empty!(lidarr_data.artists); assert!(!lidarr_data.delete_artist_files); assert_is_empty!(lidarr_data.disk_space_vec); @@ -151,6 +153,14 @@ mod tests { assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt)); } + #[test] + fn test_add_artist_blocks_contents() { + assert_eq!(ADD_ARTIST_BLOCKS.len(), 3); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistEmptySearchResults)); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchInput)); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchResults)); + } + #[test] fn test_delete_artist_blocks_contents() { assert_eq!(DELETE_ARTIST_BLOCKS.len(), 4); diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index b0045f1..ec64c38 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,11 +1,15 @@ #[cfg(test)] mod tests { use crate::models::lidarr_models::{ - Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, NewItemMonitorType, + AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, + NewItemMonitorType, }; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkResource; use crate::network::lidarr_network::LidarrEvent; - use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use bimap::BiMap; use mockito::Matcher; @@ -356,4 +360,83 @@ mod tests { async_details_server.assert_async().await; async_edit_server.assert_async().await; } + + #[tokio::test] + async fn test_handle_search_new_artist_event() { + let search_results_json = + json!([serde_json::from_str::(ADD_ARTIST_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%20artist") + .build_for(LidarrEvent::SearchNewArtist("test artist".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("test artist".to_owned())) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::AddArtistSearchResults(search_results) = result.unwrap() else { + panic!("Expected AddArtistSearchResults"); + }; + + assert_eq!(search_results, expected_results); + assert_some!(&app.lock().await.data.lidarr_data.add_searched_artists); + } + + #[tokio::test] + async fn test_handle_search_new_artist_event_navigates_to_empty_results_when_empty() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("term=nonexistent") + .build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + app + .lock() + .await + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_ok!(result); + let app = app.lock().await; + assert_none!(&app.data.lidarr_data.add_searched_artists); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_artist_event_sets_empty_table_on_api_error() { + let (mock, app, _server) = MockServarrApi::get() + .status(500) + .query("term=nonexistent") + .build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_err!(result); + let app = app.lock().await; + assert_some!(&app.data.lidarr_data.add_searched_artists); + assert_is_empty!(app.data.lidarr_data.add_searched_artists.as_ref().unwrap()); + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index a9264e0..0016d08 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -3,11 +3,15 @@ use log::{debug, info, warn}; use serde_json::{Value, json}; use crate::models::Route; -use crate::models::lidarr_models::{Artist, DeleteArtistParams, EditArtistParams}; +use crate::models::lidarr_models::{ + AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams, +}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_models::CommandBody; +use crate::models::stateful_table::StatefulTable; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; +use urlencoding::encode; #[cfg(test)] #[path = "lidarr_library_network_tests.rs"] @@ -169,6 +173,46 @@ impl Network<'_, '_> { .await } + pub(in crate::network::lidarr_network) async fn search_artist( + &mut self, + query: String, + ) -> Result> { + info!("Searching for artist: {query}"); + let event = LidarrEvent::SearchNewArtist(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, |artist_vec, mut app| { + if artist_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveLidarrBlock::AddArtistEmptySearchResults.into()); + } else if let Some(add_searched_artists) = + app.data.lidarr_data.add_searched_artists.as_mut() + { + add_searched_artists.set_items(artist_vec); + } else { + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(artist_vec); + app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); + } + }) + .await; + + if result.is_err() { + self.app.lock().await.data.lidarr_data.add_searched_artists = Some(StatefulTable::default()); + } + + result + } + pub(in crate::network::lidarr_network) async fn edit_artist( &mut self, mut edit_artist_params: EditArtistParams, diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 4e6f9dc..2b05c7f 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -1,16 +1,28 @@ #[cfg(test)] -#[allow(dead_code)] // TODO: maybe remove? +#[allow(dead_code)] pub mod test_utils { use crate::models::HorizontallyScrollableText; use crate::models::lidarr_models::{ - Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, - EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, + AddArtistSearchResult, Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, + DownloadsResponse, EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, + SystemStatus, }; use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; use bimap::BiMap; use chrono::DateTime; use serde_json::Number; + pub const ADD_ARTIST_SEARCH_RESULT_JSON: &str = r#"{ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "genres": ["soundtrack"], + "ratings": { "votes": 15, "value": 8.4 } + }"#; + pub const ARTIST_JSON: &str = r#"{ "id": 1, "artistName": "Test Artist", @@ -174,4 +186,17 @@ pub mod test_utils { clear_tags: false, } } + + pub fn add_artist_search_result() -> AddArtistSearchResult { + AddArtistSearchResult { + foreign_artist_id: "test-foreign-id".to_owned(), + artist_name: "Test Artist".into(), + status: ArtistStatus::Continuing, + overview: Some("some interesting description of the artist".to_owned()), + artist_type: Some("Person".to_owned()), + disambiguation: Some("American pianist".to_owned()), + genres: vec!["soundtrack".to_owned()], + ratings: Some(ratings()), + } + } } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 0ad2a62..3406cb8 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -39,6 +39,7 @@ pub enum LidarrEvent { GetTags, HealthCheck, ListArtists, + SearchNewArtist(String), ToggleArtistMonitoring(i64), UpdateAllArtists, } @@ -61,6 +62,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetRootFolders => "/rootfolder", LidarrEvent::GetStatus => "/system/status", LidarrEvent::HealthCheck => "/health", + LidarrEvent::SearchNewArtist(_) => "/artist/lookup", } } } @@ -121,6 +123,9 @@ impl Network<'_, '_> { .await .map(LidarrSerdeable::from), LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), + LidarrEvent::SearchNewArtist(query) => { + self.search_artist(query).await.map(LidarrSerdeable::from) + } LidarrEvent::ToggleArtistMonitoring(artist_id) => self .toggle_artist_monitoring(artist_id) .await diff --git a/src/network/radarr_network/indexers/mod.rs b/src/network/radarr_network/indexers/mod.rs index a2334f5..5e78413 100644 --- a/src/network/radarr_network/indexers/mod.rs +++ b/src/network/radarr_network/indexers/mod.rs @@ -406,9 +406,15 @@ impl Network<'_, '_> { .await; if result.is_err() { - self.app.lock().await.data.radarr_data.indexer_test_all_results = Some(StatefulTable::default()); + self + .app + .lock() + .await + .data + .radarr_data + .indexer_test_all_results = Some(StatefulTable::default()); } - + result } } diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs index bc545d5..7a8b411 100644 --- a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -940,14 +940,16 @@ mod tests { async_server.assert_async().await; assert_err!(result); - assert_some!( - &app + assert_some!(&app.lock().await.data.radarr_data.indexer_test_all_results); + assert_is_empty!( + app .lock() .await .data .radarr_data .indexer_test_all_results + .as_ref() + .unwrap() ); - assert_is_empty!(app.lock().await.data.radarr_data.indexer_test_all_results.as_ref().unwrap()); } } diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index f91f2c9..bfcc1af 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -981,14 +981,7 @@ mod tests { ); async_server.assert_async().await; - assert_none!( - &app_arc - .lock() - .await - .data - .radarr_data - .add_searched_movies - ); + assert_none!(&app_arc.lock().await.data.radarr_data.add_searched_movies); assert_eq!( app_arc.lock().await.get_current_route(), ActiveRadarrBlock::AddMovieEmptySearchResults.into() @@ -1005,21 +998,23 @@ mod tests { .await; let mut network = test_network(&app_arc); - let result = network - .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) - .await; + let result = network + .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) + .await; async_server.assert_async().await; assert_err!(result); - assert_some!( - &app_arc + assert_some!(&app_arc.lock().await.data.radarr_data.add_searched_movies); + assert_is_empty!( + app_arc .lock() .await .data .radarr_data .add_searched_movies + .as_ref() + .unwrap() ); - assert_is_empty!(app_arc.lock().await.data.radarr_data.add_searched_movies.as_ref().unwrap()); } #[tokio::test] diff --git a/src/network/sonarr_network/indexers/mod.rs b/src/network/sonarr_network/indexers/mod.rs index 576a8c7..4581604 100644 --- a/src/network/sonarr_network/indexers/mod.rs +++ b/src/network/sonarr_network/indexers/mod.rs @@ -404,7 +404,13 @@ impl Network<'_, '_> { .await; if result.is_err() { - self.app.lock().await.data.sonarr_data.indexer_test_all_results = Some(StatefulTable::default()); + self + .app + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results = Some(StatefulTable::default()); } result diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs index f5512cf..fcb0032 100644 --- a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -901,12 +901,14 @@ mod tests { async_server.assert_async().await; assert_err!(result); let app = app.lock().await; - assert_some!( - &app + assert_some!(&app.data.sonarr_data.indexer_test_all_results); + assert_is_empty!( + app .data .sonarr_data .indexer_test_all_results + .as_ref() + .unwrap() ); - assert_is_empty!(app.data.sonarr_data.indexer_test_all_results.as_ref().unwrap()); } } diff --git a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs index d4b83f3..26fd6ea 100644 --- a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs +++ b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs @@ -873,7 +873,6 @@ mod tests { .query("term=test%20term") .build_for(SonarrEvent::SearchNewSeries("test term".into())) .await; - app.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); app.lock().await.server_tabs.next(); let mut network = test_network(&app); @@ -953,23 +952,15 @@ mod tests { app.lock().await.server_tabs.next(); let mut network = test_network(&app); - let result = - network - .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) - .await; + let result = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) + .await; async_server.assert_async().await; assert_err!(result); let app = app.lock().await; - assert_some!( - &app - .data - .sonarr_data - .add_searched_series - ); - assert_is_empty!( - app.data.sonarr_data.add_searched_series.as_ref().unwrap() - ); + assert_some!(&app.data.sonarr_data.add_searched_series); + assert_is_empty!(app.data.sonarr_data.add_searched_series.as_ref().unwrap()); } #[tokio::test] diff --git a/src/ui/lidarr_ui/library/add_artist_ui.rs b/src/ui/lidarr_ui/library/add_artist_ui.rs new file mode 100644 index 0000000..497a98c --- /dev/null +++ b/src/ui/lidarr_ui/library/add_artist_ui.rs @@ -0,0 +1,151 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::App; +use crate::models::Route; +use crate::models::lidarr_models::AddArtistSearchResult; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block, title_block_centered}; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_artist_ui_tests.rs"] +mod add_artist_ui_tests; + +pub(super) struct AddArtistUi; + +impl DrawUi for AddArtistUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_add_artist_search, Size::Large); + } +} + +fn draw_add_artist_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.lidarr_data.add_searched_artists.is_none(); + let current_selection = if let Some(add_searched_artists) = + app.data.lidarr_data.add_searched_artists.as_ref() + && !add_searched_artists.is_empty() + { + add_searched_artists.current_selection().clone() + } else { + AddArtistSearchResult::default() + }; + + let [search_box_area, results_area] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) + .margin(1) + .areas(area); + let block_content = &app + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search must be populated") + .text; + let offset = app + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search must be populated") + .offset + .load(Ordering::SeqCst); + + let search_results_row_mapping = |artist: &AddArtistSearchResult| { + let rating = artist + .ratings + .as_ref() + .map_or(String::new(), |r| format!("{:.1}", r.value)); + let in_library = if app + .data + .lidarr_data + .artists + .items + .iter() + .any(|a| a.foreign_artist_id == artist.foreign_artist_id) + { + "✔" + } else { + "" + }; + + artist.artist_name.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *artist == current_selection, + app.ui_scroll_tick_count == 0, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(artist.artist_name.to_string()), + Cell::from(artist.artist_type.clone().unwrap_or_default()), + Cell::from(artist.status.to_display_str()), + Cell::from(rating), + Cell::from(artist.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + match active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Artist")); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block().default(), results_area); + f.render_widget(search_box, search_box_area); + } + ActiveLidarrBlock::AddArtistEmptySearchResults => { + let error_message = Message::new("No artists found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block().default(), results_area); + f.render_widget(error_message_popup, f.area()); + } + ActiveLidarrBlock::AddArtistSearchResults => { + let search_results_table = ManagarrTable::new( + app.data.lidarr_data.add_searched_artists.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block().default()) + .headers(["✔", "Name", "Type", "Status", "Rating", "Genres"]) + .constraints([ + Constraint::Percentage(3), + Constraint::Percentage(27), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(8), + Constraint::Percentage(38), + ]); + + f.render_widget(search_results_table, results_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Artist")), + search_box_area, + ); +} diff --git a/src/ui/lidarr_ui/library/add_artist_ui_tests.rs b/src/ui/lidarr_ui/library/add_artist_ui_tests.rs new file mode 100644 index 0000000..f756c07 --- /dev/null +++ b/src/ui/lidarr_ui/library/add_artist_ui_tests.rs @@ -0,0 +1,61 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::add_artist_ui::AddArtistUi; + + #[test] + fn test_add_artist_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(AddArtistUi::accepts(active_lidarr_block.into())); + } else { + assert!(!AddArtistUi::accepts(active_lidarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + use rstest::rstest; + + #[test] + fn test_add_artist_ui_renders_loading_for_search() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + app.data.lidarr_data.add_searched_artists = None; + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_add_artist_ui_renders( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistSearchResults, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.add_artist_search = Some("Test Artist".into()); + app.push_navigation_stack(active_lidarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_artist_ui_{active_lidarr_block}"), output); + } + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 645be1c..040d96e 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, + ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; @@ -18,6 +18,7 @@ mod tests { library_ui_blocks.extend(LIBRARY_BLOCKS); library_ui_blocks.extend(DELETE_ARTIST_BLOCKS); library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); + library_ui_blocks.extend(ADD_ARTIST_BLOCKS); for active_lidarr_block in ActiveLidarrBlock::iter() { if library_ui_blocks.contains(&active_lidarr_block) { diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 57c2c6c..115c048 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use add_artist_ui::AddArtistUi; use delete_artist_ui::DeleteArtistUi; use edit_artist_ui::EditArtistUi; use ratatui::{ @@ -26,6 +27,7 @@ use crate::{ }, }; +mod add_artist_ui; mod delete_artist_ui; mod edit_artist_ui; @@ -38,7 +40,8 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Lidarr(active_lidarr_block, _) = route { - return DeleteArtistUi::accepts(route) + return AddArtistUi::accepts(route) + || DeleteArtistUi::accepts(route) || EditArtistUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_lidarr_block); } @@ -51,6 +54,7 @@ impl DrawUi for LibraryUi { draw_library(f, app, area); match route { + _ if AddArtistUi::accepts(route) => AddArtistUi::draw(f, app, area), _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), _ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area), Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap new file mode 100644 index 0000000..68d5e7f --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No artists found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap new file mode 100644 index 0000000..1f81b1d --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap new file mode 100644 index 0000000..007dda8 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> Test Artist Person Continuing 8.4 soundtrack │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap new file mode 100644 index 0000000..68d5e7f --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No artists found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap new file mode 100644 index 0000000..1f81b1d --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap new file mode 100644 index 0000000..007dda8 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> Test Artist Person Continuing 8.4 soundtrack │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap new file mode 100644 index 0000000..3c61ebc --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs index ac1a912..3fc509d 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -34,12 +34,14 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are let is_loading = app.is_loading || app.data.radarr_data.indexer_test_all_results.is_none(); let block = title_block("Test All Indexers"); - let current_selection = - if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { - test_all_results.current_selection().clone() - } else { - IndexerTestResultModalItem::default() - }; + let current_selection = if let Some(test_all_results) = + app.data.radarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; f.render_widget(block, area); let test_results_row_mapping = |result: &IndexerTestResultModalItem| { result.validation_failures.scroll_left_or_reset( diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs index bfed06a..bc02ed8 100644 --- a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -33,12 +33,14 @@ impl DrawUi for TestAllIndexersUi { fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.sonarr_data.indexer_test_all_results.is_none(); - let current_selection = - if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { - test_all_results.current_selection().clone() - } else { - IndexerTestResultModalItem::default() - }; + let current_selection = if let Some(test_all_results) = + app.data.sonarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; f.render_widget(title_block("Test All Indexers"), area); let test_results_row_mapping = |result: &IndexerTestResultModalItem| { result.validation_failures.scroll_left_or_reset(