diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 6e16140..30f042a 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -55,9 +55,14 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::ManualSeasonSearch => { - self - .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) - .await; + match self.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if season_details_modal.season_releases.is_empty() => { + self + .dispatch_network_event(SonarrEvent::GetSeasonReleases(None).into()) + .await; + } + _ => (), + } } ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { self @@ -70,9 +75,15 @@ impl<'a> App<'a> { .await; } ActiveSonarrBlock::ManualEpisodeSearch => { - self - .dispatch_network_event(SonarrEvent::GetEpisodeReleases(None).into()) - .await; + if let Some(season_details_modal) = self.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if episode_details_modal.episode_releases.is_empty() { + self + .dispatch_network_event(SonarrEvent::GetEpisodeReleases(None).into()) + .await; + } + } + } } ActiveSonarrBlock::Downloads => { self diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 79b0ccc..4cb3dce 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -7,8 +7,11 @@ mod tests { use crate::{ app::App, models::{ - servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, - sonarr_models::{Season, Series}, + servarr_data::sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, + sonarr_models::{Season, Series, SonarrRelease}, }, network::{sonarr_network::SonarrEvent, NetworkEvent}, }; @@ -115,6 +118,7 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_season_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); app .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) @@ -129,6 +133,40 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_manual_season_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::default() + }; + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_season_search_block_season_releases_non_empty() { + let mut app = App::default(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal + .season_releases + .set_items(vec![SonarrRelease::default()]); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_episode_details_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); @@ -183,6 +221,9 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_episode_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episode_details_modal = Some(EpisodeDetailsModal::default()); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); app .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) @@ -197,6 +238,42 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_manual_episode_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::default() + }; + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_episode_search_block_episode_releases_non_empty() { + let mut app = App::default(); + let mut episode_details_modal = EpisodeDetailsModal::default(); + episode_details_modal + .episode_releases + .set_items(vec![SonarrRelease::default()]); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episode_details_modal = Some(episode_details_modal); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); + + app + .dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_history_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 1a49e94..bf70390 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -351,6 +351,10 @@ mod test_utils { movie_details_modal.movie_crew.set_items(vec![$crate::models::radarr_models::Credit::default()]); movie_details_modal.movie_releases.set_items(vec![$crate::models::radarr_models::RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); + let mut season_details_modal = $crate::models::servarr_data::sonarr::modals::SeasonDetailsModal::default(); + season_details_modal.season_history.set_items(vec![$crate::models::sonarr_models::SonarrHistoryItem::default()]); + season_details_modal.episode_details_modal = Some($crate::models::servarr_data::sonarr::modals::EpisodeDetailsModal::default()); + app.data.sonarr_data.season_details_modal = Some(season_details_modal); let mut series_history = $crate::models::stateful_table::StatefulTable::default(); series_history.set_items(vec![ $crate::models::sonarr_models::SonarrHistoryItem::default(), diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 491d755..a8d61ac 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -328,31 +328,26 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } _ => (), }, - ActiveRadarrBlock::AutomaticallySearchMoviePrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = - Some(RadarrEvent::TriggerAutomaticSearch(None)); + ActiveRadarrBlock::AutomaticallySearchMoviePrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = + Some(RadarrEvent::TriggerAutomaticSearch(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } - ActiveRadarrBlock::UpdateAndScanPrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None)); + ActiveRadarrBlock::UpdateAndScanPrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } - ActiveRadarrBlock::ManualSearchConfirmPrompt => { - if key == DEFAULT_KEYBINDINGS.confirm.key { - self.app.data.radarr_data.prompt_confirm = true; - self.app.data.radarr_data.prompt_confirm_action = - Some(RadarrEvent::DownloadRelease(None)); + ActiveRadarrBlock::ManualSearchConfirmPrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.radarr_data.prompt_confirm = true; + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DownloadRelease(None)); - self.app.pop_navigation_stack(); - } + self.app.pop_navigation_stack(); } _ => (), } diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 39aea05..6b73c88 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -10,10 +10,7 @@ mod tests { use crate::event::Key; use crate::handlers::sonarr_handlers::library::{series_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, - SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::sonarr_models::{Series, SeriesStatus, SeriesType}; use crate::test_handler_delegation; @@ -543,6 +540,33 @@ mod tests { ); } + #[rstest] + fn test_delegates_season_details_blocks_to_season_details_handler( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::SearchEpisodes, + ActiveSonarrBlock::SearchEpisodesError, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::SearchSeasonHistory, + ActiveSonarrBlock::SearchSeasonHistoryError, + ActiveSonarrBlock::FilterSeasonHistory, + ActiveSonarrBlock::FilterSeasonHistoryError, + ActiveSonarrBlock::SeasonHistorySortPrompt, + ActiveSonarrBlock::SeasonHistoryDetails, + ActiveSonarrBlock::ManualSeasonSearch, + ActiveSonarrBlock::ManualSeasonSearchSortPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + LibraryHandler, + ActiveSonarrBlock::Series, + active_sonarr_block + ); + } + #[rstest] fn test_delegates_edit_series_blocks_to_edit_series_handler( #[values( @@ -768,6 +792,7 @@ mod tests { library_handler_blocks.extend(DELETE_SERIES_BLOCKS); library_handler_blocks.extend(EDIT_SERIES_BLOCKS); library_handler_blocks.extend(SERIES_DETAILS_BLOCKS); + library_handler_blocks.extend(SEASON_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_handler_blocks.contains(&active_sonarr_block) { diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 969ed25..98b1d56 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -22,6 +22,7 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::table_handler::TableHandlingConfig; @@ -32,6 +33,7 @@ mod delete_series_handler; #[path = "library_handler_tests.rs"] mod library_handler_tests; mod series_details_handler; +mod season_details_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, @@ -75,6 +77,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' SeriesDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) .handle(); } + _ if SeasonDetailsHandler::accepts(self.active_sonarr_block) => { + SeasonDetailsHandler::with(self.key, self.app, self.active_sonarr_block, self.context) + .handle(); + } _ => self.handle_key_event(), } } @@ -85,6 +91,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' || DeleteSeriesHandler::accepts(active_block) || EditSeriesHandler::accepts(active_block) || SeriesDetailsHandler::accepts(active_block) + || SeasonDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs new file mode 100644 index 0000000..88b53c3 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -0,0 +1,465 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::App; +use crate::event::Key; +use crate::handle_table_events; +use crate::handlers::sonarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::TableHandlingConfig; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; +use crate::models::servarr_models::Language; +use crate::models::sonarr_models::{ + Episode, SonarrHistoryItem, SonarrRelease, SonarrReleaseDownloadBody, +}; +use crate::models::stateful_table::SortOption; +use crate::network::sonarr_network::SonarrEvent; +use serde_json::Number; + +#[cfg(test)] +#[path = "season_details_handler_tests.rs"] +mod season_details_handler_tests; + +pub(super) struct SeasonDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl<'a, 'b> SeasonDetailsHandler<'a, 'b> { + handle_table_events!( + self, + episodes, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .episodes, + Episode + ); + handle_table_events!( + self, + season_history, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .season_history, + SonarrHistoryItem + ); + handle_table_events!( + self, + season_releases, + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is undefined") + .season_releases, + SonarrRelease + ); +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler<'a, 'b> { + fn handle(&mut self) { + let episodes_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeasonDetails.into()) + .searching_block(ActiveSonarrBlock::SearchEpisodes.into()) + .search_error_block(ActiveSonarrBlock::SearchEpisodesError.into()) + .search_field_fn(|episode: &Episode| &episode.title); + let season_history_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::SeasonHistory.into()) + .sorting_block(ActiveSonarrBlock::SeasonHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .sort_by_fn(|a: &SonarrHistoryItem, b: &SonarrHistoryItem| a.id.cmp(&b.id)) + .searching_block(ActiveSonarrBlock::SearchSeasonHistory.into()) + .search_error_block(ActiveSonarrBlock::SearchSeasonHistoryError.into()) + .search_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveSonarrBlock::FilterSeasonHistory.into()) + .filter_error_block(ActiveSonarrBlock::FilterSeasonHistoryError.into()) + .filter_field_fn(|history_item: &SonarrHistoryItem| &history_item.source_title.text); + let season_releases_table_handling_config = + TableHandlingConfig::new(ActiveSonarrBlock::ManualSeasonSearch.into()) + .sorting_block(ActiveSonarrBlock::ManualSeasonSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !self.handle_episodes_table_events(episodes_table_handling_config) + && !self.handle_season_history_table_events(season_history_table_handling_config) + && !self.handle_season_releases_table_events(season_releases_table_handling_config) + { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SEASON_DETAILS_BLOCKS.contains(&active_block) + } + + fn with( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + context: Option, + ) -> SeasonDetailsHandler<'a, 'b> { + SeasonDetailsHandler { + key, + app, + active_sonarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && if let Some(season_details_modal) = &self.app.data.sonarr_data.season_details_modal { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails => !season_details_modal.episodes.is_empty(), + ActiveSonarrBlock::SeasonHistory => !season_details_modal.season_history.is_empty(), + ActiveSonarrBlock::ManualSeasonSearch => !season_details_modal.season_releases.is_empty(), + _ => true, + } + } else { + false + } + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_sonarr_block == ActiveSonarrBlock::SeasonDetails { + self + .app + .push_navigation_stack(ActiveSonarrBlock::DeleteEpisodeFilePrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + | ActiveSonarrBlock::SeasonHistory + | ActiveSonarrBlock::ManualSeasonSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.left.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route(), + ); + } + _ if self.key == DEFAULT_KEYBINDINGS.right.key => { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + | ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + | ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonHistory => self + .app + .push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()), + ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteEpisodeFile(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeasonSearch(None)); + } + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualSeasonSearch => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { + if self.app.data.sonarr_data.prompt_confirm { + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .current_selection(); + let series_id = self.app.data.sonarr_data.series.current_selection().id; + let season_number = self + .app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails | ActiveSonarrBlock::ManualSeasonSearch => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.season_details_modal = None; + } + ActiveSonarrBlock::SeasonHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::SeasonHistory => { + if self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_items + .is_some() + { + self + .app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .filtered_items = None; + } else { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.season_details_modal = None; + } + } + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + | ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + | ActiveSonarrBlock::DeleteEpisodeFilePrompt => { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + | ActiveSonarrBlock::SeasonHistory + | ActiveSonarrBlock::ManualSeasonSearch => match self.key { + _ if self.key == DEFAULT_KEYBINDINGS.refresh.key => { + self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } + _ if self.key == DEFAULT_KEYBINDINGS.auto_search.key => { + self + .app + .push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchSeasonPrompt.into()); + } + _ => (), + }, + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::TriggerAutomaticSeasonSearch(None)); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::DeleteEpisodeFilePrompt if key == DEFAULT_KEYBINDINGS.confirm.key => { + self.app.data.sonarr_data.prompt_confirm = true; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DeleteEpisodeFile(None)); + + self.app.pop_navigation_stack(); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + if key == DEFAULT_KEYBINDINGS.confirm.key => + { + self.app.data.sonarr_data.prompt_confirm = true; + let SonarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .current_selection(); + let series_id = self.app.data.sonarr_data.series.current_selection().id; + let season_number = self + .app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + let params = SonarrReleaseDownloadBody { + guid: guid.clone(), + indexer_id: *indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + self.app.data.sonarr_data.prompt_confirm_action = + Some(SonarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } +} + +fn releases_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Source", + cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)), + }, + SortOption { + name: "Age", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), + }, + SortOption { + name: "Rejected", + cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)), + }, + SortOption { + name: "Title", + cmp_fn: Some(|a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }), + }, + SortOption { + name: "Indexer", + cmp_fn: Some(|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase())), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| a.size.cmp(&b.size)), + }, + SortOption { + name: "Peers", + cmp_fn: Some(|a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }), + }, + SortOption { + name: "Language", + cmp_fn: Some(|a, b| { + let default_language_vec = vec![Language { + id: 1, + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; + + language_a.cmp(language_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] +} diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs new file mode 100644 index 0000000..80f525b --- /dev/null +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -0,0 +1,982 @@ +#[cfg(test)] +mod tests { + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::App; + use crate::handlers::sonarr_handlers::library::season_details_handler::{ + releases_sorting_options, SeasonDetailsHandler, + }; + use crate::handlers::KeyEventHandler; + use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; + use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; + use crate::models::HorizontallyScrollableText; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + mod test_handle_delete { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_episode_prompt() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::DeleteEpisodeFilePrompt.into() + ); + } + + #[test] + fn test_delete_episode_prompt_no_op_when_not_ready() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_loading = true; + + SeasonDetailsHandler::with(DELETE_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + } + + mod test_handle_left_right_actions { + use super::*; + use crate::event::Key; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt + )] + active_sonarr_block: ActiveSonarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + + SeasonDetailsHandler::with(key, &mut app, active_sonarr_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + #[case( + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + #[case( + ActiveSonarrBlock::ManualSeasonSearch, + ActiveSonarrBlock::SeasonDetails + )] + fn test_season_details_tabs_left_right_action( + #[case] left_block: ActiveSonarrBlock, + #[case] right_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.is_loading = is_ready; + app.push_navigation_stack(right_block.into()); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_details_tabs + .index = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + SeasonDetailsHandler::with(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), left_block.into()); + + SeasonDetailsHandler::with(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None) + .handle(); + + assert_eq!( + app.get_current_route(), + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + ); + assert_eq!(app.get_current_route(), right_block.into()); + } + } + + mod test_handle_submit { + use super::*; + use crate::event::Key; + use crate::models::stateful_table::StatefulTable; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_season_history_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistoryDetails.into() + ); + } + + #[test] + fn test_season_history_submit_no_op_when_season_history_is_empty() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[test] + fn test_season_history_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + SonarrEvent::TriggerAutomaticSeasonSearch(None) + )] + #[case( + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + SonarrEvent::DeleteEpisodeFile(None) + )] + fn test_season_details_prompt_confirm_submit( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[test] + fn test_season_details_manual_search_confirm_prompt_confirm_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + series_id: Some(0), + season_number: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + + #[rstest] + fn test_season_details_prompt_decline_submit( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + assert_eq!(app.data.sonarr_data.prompt_confirm_action, None); + } + + #[test] + fn test_manual_season_search_submit() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_season_search_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + SeasonDetailsHandler::with( + SUBMIT_KEY, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::event::Key; + use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_season_history_details_block_esc() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()); + + SeasonDetailsHandler::with( + ESC_KEY, + &mut app, + ActiveSonarrBlock::SeasonHistoryDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + } + + #[rstest] + fn test_season_details_prompts_esc( + #[values( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt + )] + prompt_block: ActiveSonarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = is_ready; + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + + #[test] + fn test_season_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + let mut season_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![SonarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + season_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.season_details_modal.as_mut().unwrap().season_history = season_history; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, ActiveSonarrBlock::SeasonHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonHistory.into() + ); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filter + .is_none()); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_items + .is_none()); + assert!(app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .filtered_state + .is_none()); + } + + #[rstest] + fn test_season_details_tabs_esc( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert!(app.data.sonarr_data.season_details_modal.is_none()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; + use crate::network::sonarr_network::SonarrEvent; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_auto_search_key( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt.into() + ); + } + + #[rstest] + fn test_auto_search_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + } + + #[rstest] + fn test_refresh_key( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.is_loading = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.is_routing = false; + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_sonarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + #[case( + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, + SonarrEvent::TriggerAutomaticSeasonSearch(None) + )] + #[case( + ActiveSonarrBlock::DeleteEpisodeFilePrompt, + SonarrEvent::DeleteEpisodeFile(None) + )] + fn test_season_details_prompt_confirm_confirm_key( + #[case] prompt_block: ActiveSonarrBlock, + #[case] expected_action: SonarrEvent, + #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory)] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(active_sonarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!(app.get_current_route(), active_sonarr_block.into()); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(expected_action) + ); + } + + #[test] + fn test_season_details_manual_search_confirm_prompt_confirm_confirm_key() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); + + SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.sonarr_data.prompt_confirm); + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::ManualSeasonSearch.into() + ); + assert_eq!( + app.data.sonarr_data.prompt_confirm_action, + Some(SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { + guid: String::new(), + indexer_id: 0, + series_id: Some(0), + season_number: Some(0), + ..SonarrReleaseDownloadBody::default() + })) + ); + } + } + + #[test] + fn test_season_details_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeasonDetailsHandler::accepts(active_sonarr_block)); + } else { + assert!(!SeasonDetailsHandler::accepts(active_sonarr_block)); + } + }); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_loading() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.is_loading = true; + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_season_details_is_none() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_episodes_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_history_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_season_details_handler_is_not_ready_when_not_loading_and_releases_table_is_empty() { + let mut app = App::default(); + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::ManualSeasonSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[rstest] + fn test_season_details_handler_is_ready_when_not_loading_and_season_details_modal_is_populated( + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(active_sonarr_block.into()); + + let handler = SeasonDetailsHandler::with( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_sonarr_block, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[0].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Source"); + } + + #[test] + fn test_releases_sorting_options_age() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = |a, b| a.age.cmp(&b.age); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[1].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Age"); + } + + #[test] + fn test_releases_sorting_options_rejected() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[2].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Rejected"); + } + + #[test] + fn test_releases_sorting_options_title() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = |a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[3].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_releases_sorting_options_indexer() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = + |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[4].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Indexer"); + } + + #[test] + fn test_releases_sorting_options_size() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[5].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_releases_sorting_options_peers() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = |a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[6].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Peers"); + } + + #[test] + fn test_releases_sorting_options_language() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = |a, b| { + let default_language_vec = vec![Language { + id: 1, + name: "_".to_owned(), + }]; + let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; + let language_b = &b.languages.as_ref().unwrap_or(&default_language_vec)[0]; + + language_a.cmp(language_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[7].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Language"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&SonarrRelease, &SonarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[8].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + fn release_vec() -> Vec { + let release_a = SonarrRelease { + protocol: "Protocol A".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Title A"), + indexer: "Indexer A".to_owned(), + size: 1, + rejected: true, + seeders: Some(Number::from(1)), + languages: Some(vec![Language { + id: 1, + name: "Language A".to_owned(), + }]), + quality: QualityWrapper { + quality: Quality { + name: "Quality A".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + let release_b = SonarrRelease { + protocol: "Protocol B".to_owned(), + age: 2, + title: HorizontallyScrollableText::from("title B"), + indexer: "indexer B".to_owned(), + size: 2, + rejected: false, + seeders: Some(Number::from(2)), + languages: Some(vec![Language { + id: 2, + name: "Language B".to_owned(), + }]), + quality: QualityWrapper { + quality: Quality { + name: "Quality B".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + let release_c = SonarrRelease { + protocol: "Protocol C".to_owned(), + age: 3, + title: HorizontallyScrollableText::from("Title C"), + indexer: "Indexer C".to_owned(), + size: 3, + rejected: false, + seeders: None, + languages: None, + quality: QualityWrapper { + quality: Quality { + name: "Quality C".to_owned(), + }, + }, + ..SonarrRelease::default() + }; + + vec![release_a, release_b, release_c] + } +} diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 7c90581..aeffb89 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -329,22 +329,22 @@ impl Default for SeasonDetailsModal { TabRoute { title: "Episodes", route: ActiveSonarrBlock::SeasonDetails.into(), - help: build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)), + help: build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { title: "History", route: ActiveSonarrBlock::SeasonHistory.into(), - help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)), + help: build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { title: "Manual Search", route: ActiveSonarrBlock::ManualSeasonSearch.into(), - help: build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES), - contextual_help: Some(build_context_clue_string( + help: build_context_clue_string( &MANUAL_SEASON_SEARCH_CONTEXT_CLUES, - )), + ), + contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, ]), } diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index e06b06e..d51a47b 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -338,11 +338,11 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[0].help, - build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[0].contextual_help, - Some(build_context_clue_string(&SEASON_DETAILS_CONTEXT_CLUES)) + Some(build_context_clue_string(&SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); assert_str_eq!( @@ -355,11 +355,11 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[1].help, - build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[1].contextual_help, - Some(build_context_clue_string(&SEASON_HISTORY_CONTEXT_CLUES)) + Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)) ); assert_str_eq!( @@ -372,12 +372,12 @@ mod tests { ); assert_str_eq!( season_details_modal.season_details_tabs.tabs[2].help, - build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES) + build_context_clue_string(&MANUAL_SEASON_SEARCH_CONTEXT_CLUES) ); assert_eq!( season_details_modal.season_details_tabs.tabs[2].contextual_help, Some(build_context_clue_string( - &MANUAL_SEASON_SEARCH_CONTEXT_CLUES + &DETAILS_CONTEXTUAL_CONTEXT_CLUES )) ); } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 21a2a8a..5137fd0 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -333,11 +333,11 @@ pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesHistoryDetails, ]; -pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 14] = [ +pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, - ActiveSonarrBlock::SearchSeason, - ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::SearchEpisodes, + ActiveSonarrBlock::SearchEpisodesError, ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, ActiveSonarrBlock::SearchSeasonHistory, ActiveSonarrBlock::SearchSeasonHistoryError, @@ -348,6 +348,7 @@ pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 14] = [ ActiveSonarrBlock::ManualSeasonSearch, ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, ActiveSonarrBlock::ManualSeasonSearchSortPrompt, + ActiveSonarrBlock::DeleteEpisodeFilePrompt, ]; pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 3e69b23..b78beb4 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -608,11 +608,11 @@ mod tests { #[test] fn test_season_details_blocks_contents() { - assert_eq!(SEASON_DETAILS_BLOCKS.len(), 14); + assert_eq!(SEASON_DETAILS_BLOCKS.len(), 15); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonDetails)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeasonHistory)); - assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); - assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchEpisodes)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchEpisodesError)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchSeasonPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistory)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonHistoryError)); @@ -623,6 +623,7 @@ mod tests { assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearch)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchSortPrompt)); + assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::DeleteEpisodeFilePrompt)); } } } diff --git a/src/models/servarr_data/sonarr/sonarr_test_utils.rs b/src/models/servarr_data/sonarr/sonarr_test_utils.rs index 2ba4dea..b7f5885 100644 --- a/src/models/servarr_data/sonarr/sonarr_test_utils.rs +++ b/src/models/servarr_data/sonarr/sonarr_test_utils.rs @@ -27,6 +27,7 @@ pub mod utils { season_details_modal .episodes .set_items(vec![Episode::default()]); + season_details_modal.season_history.set_items(vec![SonarrHistoryItem::default()]); season_details_modal .season_releases .set_items(vec![SonarrRelease::default()]); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 791b002..3a9dadf 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -210,7 +210,7 @@ pub struct Episode { pub season_number: i64, #[serde(deserialize_with = "super::from_i64")] pub episode_number: i64, - pub title: Option, + pub title: String, pub air_date_utc: Option>, pub overview: Option, pub has_file: bool, @@ -220,7 +220,7 @@ pub struct Episode { impl Display for Episode { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.title.as_ref().unwrap_or(&String::new())) + write!(f, "{}", self.title) } } diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index ff55030..0c1ea46 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -21,7 +21,7 @@ mod tests { #[test] fn test_episode_display() { let episode = Episode { - title: Some("Test Title".to_owned()), + title: "Test Title".to_owned(), ..Episode::default() }; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 614b53c..749d7ca 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -4,6 +4,8 @@ use log::{debug, info, warn}; use serde_json::{json, Value}; use urlencoding::encode; +use super::{Network, NetworkEvent, NetworkResource}; +use crate::models::sonarr_models::DownloadStatus; use crate::{ models::{ radarr_models::IndexerTestResult, @@ -31,8 +33,6 @@ use crate::{ network::RequestMethod, utils::convert_to_gb, }; -use crate::models::sonarr_models::DownloadStatus; -use super::{Network, NetworkEvent, NetworkResource}; #[cfg(test)] #[path = "sonarr_network_tests.rs"] mod sonarr_network_tests; @@ -1568,7 +1568,7 @@ impl<'a, 'b> Network<'a, 'b> { Air Date: {air_date} Status: {status} Description: {}", - title.unwrap_or_default(), + title, overview.unwrap_or_default(), )), ..EpisodeDetailsModal::default() diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 40f14e7..7280e87 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -15,7 +15,10 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; - use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType}; + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, + DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, + }; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; @@ -2234,13 +2237,13 @@ mod test { #[tokio::test] async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { let episode_1 = Episode { - title: Some("z test".to_owned()), + title: "z test".to_owned(), episode_file: None, ..episode() }; let episode_2 = Episode { id: 2, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 2, season_number: 2, episode_number: 2, @@ -2249,7 +2252,7 @@ mod test { }; let episode_3 = Episode { id: 3, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 3, season_number: 1, episode_number: 2, @@ -2271,13 +2274,7 @@ mod test { let mut season_details_modal = SeasonDetailsModal::default(); season_details_modal.episodes.sort_asc = true; if use_custom_sorting { - let cmp_fn = |a: &Episode, b: &Episode| { - a.title - .as_ref() - .unwrap() - .to_lowercase() - .cmp(&b.title.as_ref().unwrap().to_lowercase()) - }; + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); expected_sorted_episodes.sort_by(cmp_fn); let title_sort_option = SortOption { name: "Title", @@ -2348,13 +2345,13 @@ mod test { #[tokio::test] async fn test_handle_get_episodes_event_empty_seasons_table_returns_all_episodes_by_default() { let episode_1 = Episode { - title: Some("z test".to_owned()), + title: "z test".to_owned(), episode_file: None, ..episode() }; let episode_2 = Episode { id: 2, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 2, season_number: 2, episode_number: 2, @@ -2363,7 +2360,7 @@ mod test { }; let episode_3 = Episode { id: 3, - title: Some("A test".to_owned()), + title: "A test".to_owned(), episode_file_id: 3, season_number: 1, episode_number: 2, @@ -2537,13 +2534,7 @@ mod test { .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); let mut season_details_modal = SeasonDetailsModal::default(); season_details_modal.episodes.sort_asc = true; - let cmp_fn = |a: &Episode, b: &Episode| { - a.title - .as_ref() - .unwrap() - .to_lowercase() - .cmp(&b.title.as_ref().unwrap().to_lowercase()) - }; + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); expected_episodes.sort_by(cmp_fn); let title_sort_option = SortOption { name: "Title", @@ -7255,7 +7246,7 @@ mod test { episode_file_id: 1, season_number: 1, episode_number: 1, - title: Some("Something cool".to_owned()), + title: "Something cool".to_owned(), air_date_utc: Some(DateTime::from( DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), )), diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 8b5765a..42191ac 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -1,9 +1,16 @@ use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, + DownloadRecord, DownloadStatus, Episode, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, }; use crate::models::Route; +use crate::ui::sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, @@ -11,12 +18,13 @@ use crate::ui::utils::{ use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{draw_popup, draw_tabs, DrawUi}; use crate::utils::convert_to_gb; use chrono::Utc; -use ratatui::layout::{Constraint, Rect}; -use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::prelude::{Line, Style, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; @@ -37,20 +45,12 @@ impl DrawUi for SeasonDetailsUi { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if app.data.sonarr_data.season_details_modal.is_some() { - if let Route::Sonarr(active_sonarr_block, _) = app - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_details_tabs - .get_active_route() - { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { let content_area = draw_tabs( f, popup_area, - "Season Details", + &format!("Season {} Details", app.data.sonarr_data.seasons.current_selection().season_number), &app .data .sonarr_data @@ -64,8 +64,8 @@ impl DrawUi for SeasonDetailsUi { match active_sonarr_block { ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { let prompt = format!( - "Do you want to trigger an automatic search of your indexers for season packs for the season: Season {}", - app.data.sonarr_data.seasons.current_selection().season_number + "Do you want to trigger an automatic search of your indexers for season packs for: {}", + app.data.sonarr_data.seasons.current_selection().title.as_ref().unwrap() ); let confirmation_prompt = ConfirmationPrompt::new() .title("Automatic Season Search") @@ -89,8 +89,6 @@ impl DrawUi for SeasonDetailsUi { .episodes .current_selection() .title - .as_ref() - .unwrap_or(&String::new()) ); let confirmation_prompt = ConfirmationPrompt::new() .title("Delete Episode") @@ -105,11 +103,14 @@ impl DrawUi for SeasonDetailsUi { ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { draw_manual_season_search_confirm_prompt(f, app); } + ActiveSonarrBlock::SeasonHistoryDetails => { + draw_history_item_details_popup(f, app, popup_area); + } _ => (), } }; - draw_popup(f, app, draw_season_details_popup, Size::Large); + draw_popup(f, app, draw_season_details_popup, Size::XLarge); } } } @@ -194,20 +195,20 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Row::new(vec![ Cell::from(episode_monitored.to_owned()), Cell::from(episode_number.to_string()), - Cell::from(title.clone().unwrap_or_default()), + Cell::from(title.clone()), Cell::from(air_date), Cell::from(format!("{size:.2} GB")), Cell::from(quality_profile), ]), ) }; - let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchEpisodes; let season_table = ManagarrTable::new(content, episode_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading) .footer(help_footer) - .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeason) - .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .searching(is_searching) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchEpisodesError) .headers([ "🏷", "#", @@ -329,13 +330,14 @@ fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.sonarr_data.season_details_modal.as_ref() { Some(season_details_modal) if !app.is_loading => { - let current_selection = if season_details_modal.season_releases.is_empty() { - SonarrRelease::default() + let (current_selection, is_empty) = if season_details_modal.season_releases.is_empty() { + (SonarrRelease::default(), true) } else { - season_details_modal + (season_details_modal .season_releases .current_selection() - .clone() + .clone(), + season_details_modal.season_releases.is_empty()) }; let season_release_table_footer = season_details_modal .season_details_tabs @@ -409,16 +411,22 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let release_table = ManagarrTable::new(Some(&mut season_release_table), season_release_row_mapping) .block(layout_block_top_border()) - .loading(app.is_loading) + .loading(app.is_loading || is_empty) .footer(season_release_table_footer) .sorting(active_sonarr_block == ActiveSonarrBlock::ManualSeasonSearchSortPrompt) - .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ]) .constraints([ - Constraint::Percentage(40), - Constraint::Percentage(15), - Constraint::Percentage(12), - Constraint::Percentage(13), - Constraint::Percentage(20), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Percentage(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(7), + Constraint::Percentage(10), ]); f.render_widget(release_table, area); @@ -479,14 +487,14 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> .title(title) .prompt(&prompt) .content(content_paragraph) - .yes_no_value(app.data.radarr_data.prompt_confirm); + .yes_no_value(app.data.sonarr_data.prompt_confirm); f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); } else { let confirmation_prompt = ConfirmationPrompt::new() .title(title) .prompt(&prompt) - .yes_no_value(app.data.radarr_data.prompt_confirm); + .yes_no_value(app.data.sonarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), @@ -495,6 +503,47 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> } } +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if season_details_modal.season_history.is_empty() { + SonarrHistoryItem::default() + } else { + season_details_modal + .season_history + .current_selection() + .clone() + } + } else { + SonarrHistoryItem::default() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_history_event_details(current_selection) + } + _ => create_no_data_history_event_details(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); +} + fn decorate_with_row_style<'a>( downloads_vec: &[DownloadRecord], episode: &Episode, diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 3e763f4..ea135e1 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -106,7 +106,7 @@ impl DrawUi for SeriesDetailsUi { }; draw_popup(f, app, draw_series_details_popup, Size::XXLarge); - + if SeasonDetailsUi::accepts(route) { SeasonDetailsUi::draw(f, app, area); } diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs index 40dca22..6d63126 100644 --- a/src/ui/widgets/popup.rs +++ b/src/ui/widgets/popup.rs @@ -22,6 +22,7 @@ pub enum Size { Small, Medium, Large, + XLarge, XXLarge, Long, } @@ -41,6 +42,7 @@ impl Size { Size::Small => (40, 40), Size::Medium => (60, 60), Size::Large => (75, 75), + Size::XLarge => (83, 83), Size::XXLarge => (90, 90), Size::Long => (65, 75), } diff --git a/src/ui/widgets/popup_tests.rs b/src/ui/widgets/popup_tests.rs index a7bce95..9f82a44 100644 --- a/src/ui/widgets/popup_tests.rs +++ b/src/ui/widgets/popup_tests.rs @@ -18,6 +18,7 @@ mod tests { assert_eq!(Size::Small.to_percent(), (40, 40)); assert_eq!(Size::Medium.to_percent(), (60, 60)); assert_eq!(Size::Large.to_percent(), (75, 75)); + assert_eq!(Size::XLarge.to_percent(), (83, 83)); assert_eq!(Size::XXLarge.to_percent(), (90, 90)); assert_eq!(Size::Long.to_percent(), (65, 75)); }