diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index a7f0f4c..f960177 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -102,6 +102,18 @@ pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [ ), ]; +pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [ (DEFAULT_KEYBINDINGS.tasks, "open tasks"), (DEFAULT_KEYBINDINGS.events, "open events"), diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index ff435ad..9e71030 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -2,7 +2,7 @@ mod test { use crate::app::context_clues::{ BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, - ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, + ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, ServarrContextClueProvider, }; @@ -204,6 +204,40 @@ mod test { assert_none!(indexers_context_clues_iter.next()); } + #[test] + fn test_history_context_clues() { + let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter") + ); + assert_none!(history_context_clues_iter.next()); + } + #[test] fn test_system_context_clues() { let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter(); diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 7edcee3..d585897 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -70,6 +70,25 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.network_tx = Some(tx); + + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::History) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetHistory(500).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(); diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index fbe7c5b..af9cad2 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -40,6 +40,11 @@ impl App<'_> { ) .await; } + ActiveLidarrBlock::History => { + self + .dispatch_network_event(LidarrEvent::GetHistory(500).into()) + .await + } _ => (), } diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 2a58a81..20a3bf9 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -57,18 +57,6 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ - (DEFAULT_KEYBINDINGS.submit, "details"), - (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), - (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.esc, "cancel filter"), -]; - pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 08aa67f..59a3586 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -2,8 +2,8 @@ mod tests { use crate::app::context_clues::{ BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, - ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, - ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }; use crate::app::sonarr::sonarr_context_clues::{ SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES, SonarrContextClueProvider, @@ -13,10 +13,9 @@ mod tests { key_binding::DEFAULT_KEYBINDINGS, sonarr::sonarr_context_clues::{ ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, - HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, - MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, - SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, - SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, + SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, + SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, }, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; @@ -146,40 +145,6 @@ mod tests { assert_none!(series_history_context_clues_iter.next()); } - #[test] - fn test_history_context_clues() { - let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter(); - - assert_some_eq_x!( - history_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.submit, "details") - ); - assert_some_eq_x!( - history_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) - ); - assert_some_eq_x!( - history_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) - ); - assert_some_eq_x!( - history_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) - ); - assert_some_eq_x!( - history_context_clues_iter.next(), - &( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc - ) - ); - assert_some_eq_x!( - history_context_clues_iter.next(), - &(DEFAULT_KEYBINDINGS.esc, "cancel filter") - ); - assert_none!(history_context_clues_iter.next()); - } - #[test] fn test_series_details_context_clues() { let mut series_details_context_clues_iter = SERIES_DETAILS_CONTEXT_CLUES.iter(); diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 320d560..bad1fbe 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -123,6 +123,31 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_mark_history_item_as_failed_requires_history_item_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "mark-history-item-as-failed"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_mark_history_item_as_failed_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "mark-history-item-as-failed", + "--history-item-id", + "1", + ]); + + assert_ok!(&result); + } } mod handler { @@ -364,5 +389,36 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_mark_history_item_as_failed_command() { + let expected_history_item_id = 1i64; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let mark_history_item_as_failed_command = LidarrCommand::MarkHistoryItemAsFailed { + history_item_id: expected_history_item_id, + }; + + let result = LidarrCliHandler::with( + &app_arc, + mark_history_item_as_failed_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index 2799e29..4aeb5f0 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -29,6 +29,11 @@ pub enum LidarrListCommand { }, #[command(about = "List all artists in your Lidarr library")] Artists, + #[command(about = "Fetch all Lidarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, #[command(about = "List all Lidarr metadata profiles")] MetadataProfiles, #[command(about = "List all Lidarr quality profiles")] @@ -78,6 +83,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::History { events: items } => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetHistory(items).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrListCommand::MetadataProfiles => { let resp = self .network diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index f9c6d03..7675499 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -57,6 +57,29 @@ mod tests { }; assert_eq!(album_command, expected_args); } + + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "history", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_history_default_values() { + let expected_args = LidarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "lidarr", "list", "history"]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } } mod handler { @@ -127,5 +150,31 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_list_history_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetHistory(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_history_command = LidarrListCommand::History { events: 1000 }; + + let result = + LidarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 9ddcd08..f032635 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -8,6 +8,7 @@ use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler}; use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler}; +use serde_json::json; use tokio::sync::Mutex; use trigger_automatic_search_command_handler::{ LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler, @@ -67,6 +68,15 @@ pub enum LidarrCommand { about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance" )] TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand), + #[command(about = "Mark the Lidarr history item with the given ID as 'failed'")] + MarkHistoryItemAsFailed { + #[arg( + long, + help = "The Lidarr ID of the history item you wish to mark as 'failed'", + required = true + )] + history_item_id: i64, + }, #[command(about = "Search for a new artist to add to Lidarr")] SearchNewArtist { #[arg( @@ -166,6 +176,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::MarkHistoryItemAsFailed { history_item_id } => { + let _ = self + .network + .handle_network_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) + .await?; + serde_json::to_string_pretty(&json!({"message": "Lidarr history item marked as 'failed'"}))? + } LidarrCommand::SearchNewArtist { query } => { let resp = self .network diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 2a3c3c3..6495db3 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -10,6 +10,7 @@ use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler}; use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; +use serde_json::json; use tokio::sync::Mutex; use trigger_automatic_search_command_handler::{ SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler, @@ -251,7 +252,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .network .handle_network_event(SonarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) .await?; - "Sonarr history item marked as 'failed'".to_owned() + serde_json::to_string_pretty(&json!({"message": "Sonarr history item marked as 'failed'"}))? } SonarrCommand::SearchNewSeries { query } => { let resp = self diff --git a/src/handlers/lidarr_handlers/history/history_handler_tests.rs b/src/handlers/lidarr_handlers/history/history_handler_tests.rs new file mode 100644 index 0000000..8dc7c31 --- /dev/null +++ b/src/handlers/lidarr_handlers/history/history_handler_tests.rs @@ -0,0 +1,394 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::history::{HistoryHandler, history_sorting_options}; + use crate::models::lidarr_models::{LidarrHistoryEventType, LidarrHistoryItem}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_history_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.is_loading = is_ready; + app.data.lidarr_data.main_tabs.set_index(1); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + ActiveLidarrBlock::Artists.into() + ); + assert_navigation_pushed!(app, ActiveLidarrBlock::Artists.into()); + } + + #[rstest] + fn test_history_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.is_loading = is_ready; + app.data.lidarr_data.main_tabs.set_index(1); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + ActiveLidarrBlock::Artists.into() + ); + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_history_submit() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::History, None).handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::HistoryItemDetails.into()); + } + + #[test] + fn test_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_esc_history_item_details() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.push_navigation_stack(ActiveLidarrBlock::HistoryItemDetails.into()); + + HistoryHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::HistoryItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::History.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app + .data + .lidarr_data + .history + .set_items(vec![LidarrHistoryItem::default()]); + + HistoryHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_history_key() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::History.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_history_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); + assert!(!app.should_refresh); + } + } + + #[test] + fn test_history_sorting_options_source_title() { + let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[0].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_history_sorting_options_event_type() { + let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[1].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Event Type"); + } + + #[test] + fn test_history_sorting_options_quality() { + let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[2].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_history_sorting_options_date() { + let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[3].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + #[test] + fn test_history_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if HISTORY_BLOCKS.contains(&active_lidarr_block) { + assert!(HistoryHandler::accepts(active_lidarr_block)); + } else { + assert!(!HistoryHandler::accepts(active_lidarr_block)); + } + }) + } + + #[rstest] + fn test_history_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 = HistoryHandler::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_history_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.is_loading = true; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_not_ready_when_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.is_loading = false; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.is_loading = false; + app + .data + .lidarr_data + .history + .set_items(vec![LidarrHistoryItem::default()]); + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::History, + None, + ); + + assert!(handler.is_ready()); + } + + fn history_vec() -> Vec { + vec![ + LidarrHistoryItem { + id: 3, + source_title: "test 1".into(), + event_type: LidarrHistoryEventType::Grabbed, + quality: QualityWrapper { + quality: Quality { + name: "FLAC".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + ..LidarrHistoryItem::default() + }, + LidarrHistoryItem { + id: 2, + source_title: "test 2".into(), + event_type: LidarrHistoryEventType::DownloadImported, + quality: QualityWrapper { + quality: Quality { + name: "MP3-320".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + ..LidarrHistoryItem::default() + }, + LidarrHistoryItem { + id: 1, + source_title: "test 3".into(), + event_type: LidarrHistoryEventType::TrackFileDeleted, + quality: QualityWrapper { + quality: Quality { + name: "FLAC".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + ..LidarrHistoryItem::default() + }, + ] + } +} diff --git a/src/handlers/lidarr_handlers/history/mod.rs b/src/handlers/lidarr_handlers/history/mod.rs new file mode 100644 index 0000000..11a2ea7 --- /dev/null +++ b/src/handlers/lidarr_handlers/history/mod.rs @@ -0,0 +1,165 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::lidarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::Route; +use crate::models::lidarr_models::LidarrHistoryItem; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS}; +use crate::models::stateful_table::SortOption; + +#[cfg(test)] +#[path = "history_handler_tests.rs"] +mod history_handler_tests; + +pub(super) struct HistoryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl HistoryHandler<'_, '_> {} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for HistoryHandler<'a, 'b> { + fn handle(&mut self) { + let history_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::History.into()) + .sorting_block(ActiveLidarrBlock::HistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveLidarrBlock::SearchHistory.into()) + .search_error_block(ActiveLidarrBlock::SearchHistoryError.into()) + .search_field_fn(|history| &history.source_title.text) + .filtering_block(ActiveLidarrBlock::FilterHistory.into()) + .filter_error_block(ActiveLidarrBlock::FilterHistoryError.into()) + .filter_field_fn(|history| &history.source_title.text); + + if !handle_table( + self, + |app| &mut app.data.lidarr_data.history, + history_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + HISTORY_BLOCKS.contains(&active_block) + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> Self { + HistoryHandler { + key, + app, + active_lidarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.lidarr_data.history.is_empty() + } + + 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) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::History { + handle_change_tab_left_right_keys(self.app, self.key) + } + } + + fn handle_submit(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::History { + self + .app + .push_navigation_stack(ActiveLidarrBlock::HistoryItemDetails.into()); + } + } + + fn handle_esc(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::HistoryItemDetails { + self.app.pop_navigation_stack(); + } else { + handle_clear_errors(self.app); + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_lidarr_block == ActiveLidarrBlock::History { + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +pub(in crate::handlers::lidarr_handlers) fn history_sorting_options() +-> Vec> { + vec![ + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }), + }, + SortOption { + name: "Event Type", + cmp_fn: Some(|a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index 484d367..598c1de 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -2,9 +2,11 @@ mod tests { use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; use crate::handlers::KeyEventHandler; - use crate::handlers::lidarr_handlers::LidarrHandler; + use crate::handlers::lidarr_handlers::{LidarrHandler, handle_change_tab_left_right_keys}; use crate::models::lidarr_models::Artist; + use crate::models::lidarr_models::LidarrHistoryItem; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::lidarr::modals::EditArtistModal; use pretty_assertions::assert_eq; @@ -52,6 +54,97 @@ mod tests { } } + #[rstest] + #[case(0, ActiveLidarrBlock::History, ActiveLidarrBlock::History)] + #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Artists)] + fn test_lidarr_handler_change_tab_left_right_keys( + #[case] index: usize, + #[case] left_block: ActiveLidarrBlock, + #[case] right_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveLidarrBlock::History, ActiveLidarrBlock::History)] + #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Artists)] + fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation( + #[case] index: usize, + #[case] left_block: ActiveLidarrBlock, + #[case] right_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveLidarrBlock::Artists)] + #[case(1, ActiveLidarrBlock::History)] + fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key( + #[case] index: usize, + #[case] block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(block.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + + app.data.lidarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + } + #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( @@ -92,4 +185,37 @@ mod tests { assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); } + + #[rstest] + fn test_delegates_history_blocks_to_history_handler( + #[values( + ActiveLidarrBlock::History, + ActiveLidarrBlock::HistoryItemDetails, + ActiveLidarrBlock::HistorySortPrompt, + ActiveLidarrBlock::FilterHistory, + ActiveLidarrBlock::FilterHistoryError, + ActiveLidarrBlock::SearchHistory, + ActiveLidarrBlock::SearchHistoryError + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app + .data + .lidarr_data + .history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + LidarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_lidarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); + } } diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs index 04c7499..3e23c8c 100644 --- a/src/handlers/lidarr_handlers/mod.rs +++ b/src/handlers/lidarr_handlers/mod.rs @@ -1,3 +1,4 @@ +use history::HistoryHandler; use library::LibraryHandler; use super::KeyEventHandler; @@ -6,6 +7,7 @@ use crate::{ app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, }; +mod history; mod library; #[cfg(test)] @@ -25,6 +27,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b _ if LibraryHandler::accepts(self.active_lidarr_block) => { LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); } + _ if HistoryHandler::accepts(self.active_lidarr_block) => { + HistoryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); + } _ => self.handle_key_event(), } } diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 83e380d..58455cd 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -7,7 +7,9 @@ use strum::{Display, EnumIter}; use super::{ HorizontallyScrollableText, Serdeable, - servarr_models::{DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag}, + servarr_models::{ + DiskSpace, HostConfig, QualityProfile, QualityWrapper, RootFolder, SecurityConfig, Tag, + }, }; use crate::serde_enum_from; @@ -337,6 +339,78 @@ pub struct AlbumStatistics { impl Eq for AlbumStatistics {} +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LidarrHistoryWrapper { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LidarrHistoryData { + pub indexer: Option, + pub release_group: Option, + pub nzb_info_url: Option, + pub download_client_name: Option, + pub download_client: Option, + pub age: Option, + pub published_date: Option>, + pub message: Option, + pub reason: Option, + pub dropped_path: Option, + pub imported_path: Option, + pub source_path: Option, + pub path: Option, + pub status_messages: Option, +} + +#[derive( + Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Display, EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum LidarrHistoryEventType { + #[default] + Unknown, + Grabbed, + #[display_style(name = "Artist Folder Imported")] + ArtistFolderImported, + #[display_style(name = "Album Import Incomplete")] + AlbumImportIncomplete, + #[display_style(name = "Download Ignored")] + DownloadIgnored, + #[display_style(name = "Download Imported")] + DownloadImported, + #[display_style(name = "Download Failed")] + DownloadFailed, + #[display_style(name = "Track File Deleted")] + TrackFileDeleted, + #[display_style(name = "Track File Imported")] + TrackFileImported, + #[display_style(name = "Track File Renamed")] + TrackFileRenamed, + #[display_style(name = "Track File Retagged")] + TrackFileRetagged, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LidarrHistoryItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub source_title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub album_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub artist_id: i64, + #[serde(default)] + pub quality: QualityWrapper, + pub date: DateTime, + pub event_type: LidarrHistoryEventType, + #[serde(default)] + pub data: LidarrHistoryData, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) @@ -352,6 +426,7 @@ serde_enum_from!( Artists(Vec), DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), + HistoryWrapper(LidarrHistoryWrapper), HostConfig(HostConfig), MetadataProfiles(Vec), QualityProfiles(Vec), diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index dbc8722..06b3083 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,8 +5,9 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse, Member, - MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, + AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse, + LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, Member, MetadataProfile, + MonitorType, NewItemMonitorType, SystemStatus, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag, @@ -302,6 +303,23 @@ mod tests { ); } + #[test] + fn test_lidarr_serdeable_from_history_wrapper() { + let history_wrapper = LidarrHistoryWrapper { + records: vec![LidarrHistoryItem { + id: 1, + ..LidarrHistoryItem::default() + }], + }; + + let lidarr_serdeable: LidarrSerdeable = history_wrapper.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::HistoryWrapper(history_wrapper) + ); + } + #[test] fn test_lidarr_serdeable_from_metadata_profiles() { let metadata_profiles = vec![MetadataProfile { @@ -488,6 +506,90 @@ mod tests { assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); } + #[test] + fn test_lidarr_history_event_type_display() { + assert_str_eq!(LidarrHistoryEventType::Unknown.to_string(), "unknown"); + assert_str_eq!(LidarrHistoryEventType::Grabbed.to_string(), "grabbed"); + assert_str_eq!( + LidarrHistoryEventType::ArtistFolderImported.to_string(), + "artistFolderImported" + ); + assert_str_eq!( + LidarrHistoryEventType::AlbumImportIncomplete.to_string(), + "albumImportIncomplete" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadIgnored.to_string(), + "downloadIgnored" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadImported.to_string(), + "downloadImported" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadFailed.to_string(), + "downloadFailed" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileDeleted.to_string(), + "trackFileDeleted" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileImported.to_string(), + "trackFileImported" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileRenamed.to_string(), + "trackFileRenamed" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileRetagged.to_string(), + "trackFileRetagged" + ); + } + + #[test] + fn test_lidarr_history_event_type_to_display_str() { + assert_str_eq!(LidarrHistoryEventType::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(LidarrHistoryEventType::Grabbed.to_display_str(), "Grabbed"); + assert_str_eq!( + LidarrHistoryEventType::ArtistFolderImported.to_display_str(), + "Artist Folder Imported" + ); + assert_str_eq!( + LidarrHistoryEventType::AlbumImportIncomplete.to_display_str(), + "Album Import Incomplete" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadIgnored.to_display_str(), + "Download Ignored" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadImported.to_display_str(), + "Download Imported" + ); + assert_str_eq!( + LidarrHistoryEventType::DownloadFailed.to_display_str(), + "Download Failed" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileDeleted.to_display_str(), + "Track File Deleted" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileImported.to_display_str(), + "Track File Imported" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileRenamed.to_display_str(), + "Track File Renamed" + ); + assert_str_eq!( + LidarrHistoryEventType::TrackFileRetagged.to_display_str(), + "Track File Retagged" + ); + } + #[test] fn test_add_artist_search_result_deserialization() { let search_result_json = json!({ diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 791bbc7..fca4e77 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,12 +1,13 @@ use serde_json::Number; use super::modals::{AddArtistModal, EditArtistModal}; +use crate::app::context_clues::HISTORY_CONTEXT_CLUES; use crate::app::lidarr::lidarr_context_clues::{ ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, }; use crate::models::{ BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState, - lidarr_models::{AddArtistSearchResult, Album, Artist, DownloadRecord}, + lidarr_models::{AddArtistSearchResult, Album, Artist, DownloadRecord, LidarrHistoryItem}, servarr_models::{DiskSpace, RootFolder}, stateful_table::StatefulTable, }; @@ -21,8 +22,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::{ - add_artist_search_result, album, artist, download_record, metadata_profile, - metadata_profile_map, quality_profile, root_folder, tags_map, + add_artist_search_result, album, artist, download_record, lidarr_history_item, + metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map, }, crate::network::servarr_test_utils::diskspace, strum::{Display, EnumString, IntoEnumIterator}, @@ -44,6 +45,7 @@ pub struct LidarrData<'a> { pub disk_space_vec: Vec, pub downloads: StatefulTable, pub edit_artist_modal: Option, + pub history: StatefulTable, pub main_tabs: TabState, pub metadata_profile_map: BiMap, pub prompt_confirm: bool, @@ -112,6 +114,7 @@ impl<'a> Default for LidarrData<'a> { disk_space_vec: Vec::new(), downloads: StatefulTable::default(), edit_artist_modal: None, + history: StatefulTable::default(), metadata_profile_map: BiMap::new(), prompt_confirm: false, prompt_confirm_action: None, @@ -121,12 +124,20 @@ impl<'a> Default for LidarrData<'a> { start_time: DateTime::default(), tags_map: BiMap::new(), version: String::new(), - main_tabs: TabState::new(vec![TabRoute { - title: "Library".to_string(), - route: ActiveLidarrBlock::Artists.into(), - contextual_help: Some(&ARTISTS_CONTEXT_CLUES), - config: None, - }]), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library".to_string(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: Some(&ARTISTS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveLidarrBlock::History.into(), + contextual_help: Some(&HISTORY_CONTEXT_CLUES), + config: None, + }, + ]), artist_info_tabs: TabState::new(vec![TabRoute { title: "Albums".to_string(), route: ActiveLidarrBlock::ArtistDetails.into(), @@ -195,6 +206,13 @@ impl LidarrData<'_> { lidarr_data.artists.search = Some("artist search".into()); lidarr_data.artists.filter = Some("artist filter".into()); lidarr_data.downloads.set_items(vec![download_record()]); + lidarr_data.history.set_items(vec![lidarr_history_item()]); + lidarr_data.history.sorting(vec![SortOption { + name: "Date", + cmp_fn: Some(|a: &LidarrHistoryItem, b: &LidarrHistoryItem| a.date.cmp(&b.date)), + }]); + lidarr_data.history.search = Some("test search".into()); + lidarr_data.history.filter = Some("test filter".into()); 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()); @@ -244,10 +262,17 @@ pub enum ActiveLidarrBlock { EditArtistToggleMonitored, FilterArtists, FilterArtistsError, + FilterHistory, + FilterHistoryError, + History, + HistoryItemDetails, + HistorySortPrompt, SearchAlbums, SearchAlbumsError, SearchArtists, SearchArtistsError, + SearchHistory, + SearchHistoryError, UpdateAllArtistsPrompt, UpdateAndScanArtistPrompt, } @@ -270,6 +295,16 @@ pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 5] = [ ActiveLidarrBlock::UpdateAndScanArtistPrompt, ]; +pub static HISTORY_BLOCKS: [ActiveLidarrBlock; 7] = [ + ActiveLidarrBlock::History, + ActiveLidarrBlock::HistoryItemDetails, + ActiveLidarrBlock::HistorySortPrompt, + ActiveLidarrBlock::SearchHistory, + ActiveLidarrBlock::SearchHistoryError, + ActiveLidarrBlock::FilterHistory, + ActiveLidarrBlock::FilterHistoryError, +]; + pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 12] = [ ActiveLidarrBlock::AddArtistAlreadyInLibrary, ActiveLidarrBlock::AddArtistConfirmPrompt, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index f995d5e..7d7e9e7 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use crate::app::context_clues::HISTORY_CONTEXT_CLUES; use crate::app::lidarr::lidarr_context_clues::{ ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, }; @@ -7,7 +8,7 @@ mod tests { use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ARTIST_DETAILS_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, - EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, + EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, HISTORY_BLOCKS, }; use crate::models::{ BlockSelectionState, Route, @@ -134,6 +135,7 @@ mod tests { assert_is_empty!(lidarr_data.disk_space_vec); assert_is_empty!(lidarr_data.downloads); assert_none!(lidarr_data.edit_artist_modal); + assert_is_empty!(lidarr_data.history); assert_is_empty!(lidarr_data.metadata_profile_map); assert!(!lidarr_data.prompt_confirm); assert_none!(lidarr_data.prompt_confirm_action); @@ -144,7 +146,7 @@ mod tests { assert_is_empty!(lidarr_data.tags_map); assert_is_empty!(lidarr_data.version); - assert_eq!(lidarr_data.main_tabs.tabs.len(), 1); + assert_eq!(lidarr_data.main_tabs.tabs.len(), 2); assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library"); assert_eq!( @@ -157,6 +159,17 @@ mod tests { ); assert_none!(lidarr_data.main_tabs.tabs[0].config); + assert_str_eq!(lidarr_data.main_tabs.tabs[1].title, "History"); + assert_eq!( + lidarr_data.main_tabs.tabs[1].route, + ActiveLidarrBlock::History.into() + ); + assert_some_eq_x!( + &lidarr_data.main_tabs.tabs[1].contextual_help, + &HISTORY_CONTEXT_CLUES + ); + assert_none!(lidarr_data.main_tabs.tabs[1].config); + assert_eq!(lidarr_data.artist_info_tabs.tabs.len(), 1); assert_str_eq!(lidarr_data.artist_info_tabs.tabs[0].title, "Albums"); assert_eq!( @@ -192,6 +205,18 @@ mod tests { assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt)); } + #[test] + fn test_history_blocks_contains_expected_blocks() { + assert_eq!(HISTORY_BLOCKS.len(), 7); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::History)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::HistoryItemDetails)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::HistorySortPrompt)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::SearchHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::SearchHistoryError)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::FilterHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::FilterHistoryError)); + } + #[test] fn test_add_artist_blocks_contents() { assert_eq!(ADD_ARTIST_BLOCKS.len(), 12); diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 3eb6552..b22ec09 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -2,12 +2,11 @@ use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; use crate::{ app::{ context_clues::{ - BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, - ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }, sonarr::sonarr_context_clues::{ - HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, - SERIES_HISTORY_CONTEXT_CLUES, + SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, }, }, models::{ diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index e9c71ce..25c5756 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { mod sonarr_data_tests { + use crate::app::context_clues::HISTORY_CONTEXT_CLUES; use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::stateful_table::StatefulTable; @@ -10,9 +11,7 @@ mod tests { BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }, - sonarr::sonarr_context_clues::{ - HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, - }, + sonarr::sonarr_context_clues::{SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES}, }, models::{ BlockSelectionState, Route, diff --git a/src/network/lidarr_network/history/lidarr_history_network_tests.rs b/src/network/lidarr_network/history/lidarr_history_network_tests.rs new file mode 100644 index 0000000..b605ee6 --- /dev/null +++ b/src/network/lidarr_network/history/lidarr_history_network_tests.rs @@ -0,0 +1,199 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{LidarrHistoryItem, LidarrHistoryWrapper, LidarrSerdeable}; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::lidarr_history_item; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[tokio::test] + async fn test_handle_get_lidarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z album", + "albumId": 1007, + "artistId": 1007, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }, + { + "id": 456, + "sourceTitle": "An Album", + "albumId": 2001, + "artistId": 2001, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }]}); + let response: LidarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=500&sortDirection=descending&sortKey=date") + .build_for(LidarrEvent::GetHistory(500)) + .await; + let mut expected_history_items = vec![ + LidarrHistoryItem { + id: 123, + album_id: 1007, + artist_id: 1007, + source_title: "z album".into(), + ..lidarr_history_item() + }, + LidarrHistoryItem { + id: 456, + album_id: 2001, + artist_id: 2001, + source_title: "An Album".into(), + ..lidarr_history_item() + }, + ]; + { + let mut app_mut = app.lock().await; + app_mut.server_tabs.set_index(2); + app_mut.data.lidarr_data.history.sort_asc = true; + } + if use_custom_sorting { + let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .lidarr_data + .history + .sorting(vec![history_sort_option]); + } + + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetHistory(500)) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + let LidarrSerdeable::HistoryWrapper(history) = result.unwrap() else { + panic!("Expected LidarrHistoryWrapper") + }; + assert_eq!( + app.lock().await.data.lidarr_data.history.items, + expected_history_items + ); + assert!(app.lock().await.data.lidarr_data.history.sort_asc); + assert_eq!(history, response); + } + + #[tokio::test] + async fn test_handle_get_lidarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z album", + "albumId": 1007, + "artistId": 1007, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }, + { + "id": 456, + "sourceTitle": "An Album", + "albumId": 2001, + "artistId": 2001, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }]}); + let response: LidarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=500&sortDirection=descending&sortKey=date") + .build_for(LidarrEvent::GetHistory(500)) + .await; + app.lock().await.data.lidarr_data.history.sort_asc = true; + app + .lock() + .await + .push_navigation_stack(ActiveLidarrBlock::HistorySortPrompt.into()); + let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .lidarr_data + .history + .sorting(vec![history_sort_option]); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::HistoryWrapper(history) = network + .handle_lidarr_event(LidarrEvent::GetHistory(500)) + .await + .unwrap() + else { + panic!("Expected LidarrHistoryWrapper") + }; + mock.assert_async().await; + assert!(app.lock().await.data.lidarr_data.history.is_empty()); + assert!(app.lock().await.data.lidarr_data.history.sort_asc); + assert_eq!(history, response); + } + + #[tokio::test] + async fn test_handle_mark_lidarr_history_item_as_failed_event() { + let history_item_id = 1234i64; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .path("/1234") + .build_for(LidarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + } +} diff --git a/src/network/lidarr_network/history/mod.rs b/src/network/lidarr_network/history/mod.rs new file mode 100644 index 0000000..de6a069 --- /dev/null +++ b/src/network/lidarr_network/history/mod.rs @@ -0,0 +1,63 @@ +use crate::models::Route; +use crate::models::lidarr_models::LidarrHistoryWrapper; +use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "lidarr_history_network_tests.rs"] +mod lidarr_history_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn get_lidarr_history( + &mut self, + events: u64, + ) -> Result { + info!("Fetching all Lidarr history events"); + let event = LidarrEvent::GetHistory(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=date"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LidarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Lidarr(ActiveLidarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.lidarr_data.history.set_items(history_vec); + app.data.lidarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::lidarr_network) async fn mark_lidarr_history_item_as_failed( + &mut self, + history_item_id: i64, + ) -> Result { + info!("Marking the Lidarr history item with ID: {history_item_id} as 'failed'"); + let event = LidarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 213ec49..9c5cdd3 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -4,10 +4,11 @@ pub mod test_utils { use crate::models::HorizontallyScrollableText; use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus, - DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, Member, MetadataProfile, + DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, LidarrHistoryData, + LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, }; - use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; + use crate::models::servarr_models::{Quality, QualityProfile, QualityWrapper, RootFolder, Tag}; use bimap::BiMap; use chrono::DateTime; use serde_json::Number; @@ -120,6 +121,16 @@ pub mod test_utils { } } + pub fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + pub fn quality() -> Quality { + Quality { + name: "Lossless".to_string(), + } + } + pub fn quality_profile() -> QualityProfile { QualityProfile { id: 1, @@ -249,4 +260,31 @@ pub mod test_utils { statistics: Some(album_statistics()), } } + + pub fn lidarr_history_wrapper() -> LidarrHistoryWrapper { + LidarrHistoryWrapper { + records: vec![lidarr_history_item()], + } + } + + pub fn lidarr_history_item() -> LidarrHistoryItem { + LidarrHistoryItem { + id: 1, + source_title: "Test source title".into(), + album_id: 1, + artist_id: 1, + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + event_type: LidarrHistoryEventType::Grabbed, + data: lidarr_history_data(), + } + } + + pub fn lidarr_history_data() -> LidarrHistoryData { + LidarrHistoryData { + dropped_path: Some("/nfs/nzbget/completed/music/Something/cool.mp3".to_owned()), + imported_path: Some("/nfs/music/Something/Album 1/Cool.mp3".to_owned()), + ..LidarrHistoryData::default() + } + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index c5292f3..682bca7 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -30,6 +30,11 @@ mod tests { assert_str_eq!(event.resource(), "/artist"); } + #[rstest] + fn test_resource_history(#[values(LidarrEvent::GetHistory(0))] event: LidarrEvent) { + assert_str_eq!(event.resource(), "/history"); + } + #[rstest] fn test_resource_tag( #[values( @@ -83,6 +88,7 @@ mod tests { #[case(LidarrEvent::GetStatus, "/system/status")] #[case(LidarrEvent::GetTags, "/tag")] #[case(LidarrEvent::HealthCheck, "/health")] + #[case(LidarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index d2be3cb..cf9086d 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -9,6 +9,7 @@ use crate::models::servarr_models::{QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod downloads; +mod history; mod library; mod root_folders; mod system; @@ -34,7 +35,9 @@ pub enum LidarrEvent { GetArtistDetails(i64), GetDiskSpace, GetDownloads(u64), + GetHistory(u64), GetHostConfig, + MarkHistoryItemAsFailed(i64), GetMetadataProfiles, GetQualityProfiles, GetRootFolders, @@ -67,6 +70,8 @@ impl NetworkResource for LidarrEvent { | LidarrEvent::DeleteAlbum(_) => "/album", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", + LidarrEvent::GetHistory(_) => "/history", + LidarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", LidarrEvent::TriggerAutomaticArtistSearch(_) | LidarrEvent::UpdateAllArtists @@ -120,6 +125,14 @@ impl Network<'_, '_> { .get_lidarr_downloads(count) .await .map(LidarrSerdeable::from), + LidarrEvent::GetHistory(events) => self + .get_lidarr_history(events) + .await + .map(LidarrSerdeable::from), + LidarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .mark_lidarr_history_item_as_failed(history_item_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::GetHostConfig => self .get_lidarr_host_config() .await diff --git a/src/ui/lidarr_ui/history/history_ui_tests.rs b/src/ui/lidarr_ui/history/history_ui_tests.rs new file mode 100644 index 0000000..f46b50f --- /dev/null +++ b/src/ui/lidarr_ui/history/history_ui_tests.rs @@ -0,0 +1,79 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::history::HistoryUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_history_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if HISTORY_BLOCKS.contains(&active_lidarr_block) { + assert!(HistoryUi::accepts(active_lidarr_block.into())); + } else { + assert!(!HistoryUi::accepts(active_lidarr_block.into())); + } + }); + } + + mod snapshot_tests { + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[test] + fn test_history_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_history_ui_renders_empty( + #[values(ActiveLidarrBlock::History, ActiveLidarrBlock::HistoryItemDetails)] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("loading_history_tab_{active_lidarr_block}"), output); + } + + #[rstest] + fn test_history_ui_renders( + #[values( + ActiveLidarrBlock::History, + ActiveLidarrBlock::HistoryItemDetails, + ActiveLidarrBlock::HistorySortPrompt, + ActiveLidarrBlock::FilterHistory, + ActiveLidarrBlock::FilterHistoryError, + ActiveLidarrBlock::SearchHistory, + ActiveLidarrBlock::SearchHistoryError + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_lidarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("history_tab_{active_lidarr_block}"), output); + } + } +} diff --git a/src/ui/lidarr_ui/history/mod.rs b/src/ui/lidarr_ui/history/mod.rs new file mode 100644 index 0000000..32ce649 --- /dev/null +++ b/src/ui/lidarr_ui/history/mod.rs @@ -0,0 +1,120 @@ +use super::lidarr_ui_utils::create_history_event_details; +use crate::app::App; +use crate::models::Route; +use crate::models::lidarr_models::LidarrHistoryItem; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::Constraint; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; + +#[cfg(test)] +#[path = "history_ui_tests.rs"] +mod history_ui_tests; + +pub(super) struct HistoryUi; + +impl DrawUi for HistoryUi { + fn accepts(route: Route) -> bool { + if let Route::Lidarr(active_lidarr_block, _) = route { + return HISTORY_BLOCKS.contains(&active_lidarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + draw_history_table(f, app, area); + + if active_lidarr_block == ActiveLidarrBlock::HistoryItemDetails { + draw_history_item_details_popup(f, app); + } + } + } +} + +fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.lidarr_data.history.items.is_empty() { + LidarrHistoryItem::default() + } else { + app.data.lidarr_data.history.current_selection().clone() + }; + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &LidarrHistoryItem| { + let LidarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 50), + current_selection == *history_item, + app.ui_scroll_tick_count == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = + ManagarrTable::new(Some(&mut app.data.lidarr_data.history), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_lidarr_block == ActiveLidarrBlock::HistorySortPrompt) + .searching(active_lidarr_block == ActiveLidarrBlock::SearchHistory) + .search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchHistoryError) + .filtering(active_lidarr_block == ActiveLidarrBlock::FilterHistory) + .filter_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::FilterHistoryError) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(18), + Constraint::Percentage(12), + Constraint::Percentage(20), + ]); + + if [ + ActiveLidarrBlock::SearchHistory, + ActiveLidarrBlock::FilterHistory, + ] + .contains(&active_lidarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.lidarr_data.history.items.is_empty() { + LidarrHistoryItem::default() + } else { + app.data.lidarr_data.history.current_selection().clone() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap new file mode 100644 index 0000000..657ae85 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │test filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap new file mode 100644 index 0000000..e8ff449 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap new file mode 100644 index 0000000..6ed3c67 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap new file mode 100644 index 0000000..bc4be61 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: Test source title │ + │Event Type: grabbed │ + │Quality: Lossless │ + │Date: 2023-01-01 00:00:00 UTC │ + │Indexer: │ + │NZB Info URL: │ + │Release Group: │ + │Age: 0 days │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap new file mode 100644 index 0000000..37c8e24 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Date │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap new file mode 100644 index 0000000..68de9a8 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │test search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap new file mode 100644 index 0000000..c1f02a7 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap new file mode 100644 index 0000000..7d9fc6c --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_History.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_History.snap new file mode 100644 index 0000000..42913ba --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_History.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_HistoryItemDetails.snap b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_HistoryItemDetails.snap new file mode 100644 index 0000000..3887486 --- /dev/null +++ b/src/ui/lidarr_ui/history/snapshots/managarr__ui__lidarr_ui__history__history_ui_tests__tests__snapshot_tests__loading_history_tab_HistoryItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/lidarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: │ + │Event Type: unknown │ + │Quality: │ + │Date: 1970-01-01 00:00:00 UTC │ + │No additional details available. │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/lidarr_ui_utils.rs b/src/ui/lidarr_ui/lidarr_ui_utils.rs new file mode 100644 index 0000000..eff4c74 --- /dev/null +++ b/src/ui/lidarr_ui/lidarr_ui_utils.rs @@ -0,0 +1,153 @@ +use crate::models::lidarr_models::{LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem}; +use ratatui::text::Line; + +#[cfg(test)] +#[path = "lidarr_ui_utils_tests.rs"] +mod lidarr_ui_utils_tests; + +pub(super) fn create_history_event_details(history_item: LidarrHistoryItem) -> Vec> { + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item; + let LidarrHistoryData { + indexer, + nzb_info_url, + release_group, + age, + published_date, + download_client_name, + download_client, + message, + reason, + dropped_path, + imported_path, + source_path, + path, + status_messages, + } = data; + + let mut lines = vec![ + Line::from(format!("Source Title: {}", source_title.text)), + Line::from(format!("Event Type: {event_type}")), + Line::from(format!("Quality: {}", quality.quality.name)), + Line::from(format!("Date: {date}")), + ]; + + match event_type { + LidarrHistoryEventType::Grabbed => { + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "NZB Info URL: {}", + nzb_info_url.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Age: {} days", + age.unwrap_or("0".to_owned()) + ))); + lines.push(Line::from(format!( + "Published Date: {}", + published_date.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name.unwrap_or(download_client.unwrap_or_default()) + ))); + } + LidarrHistoryEventType::DownloadImported => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + LidarrHistoryEventType::DownloadFailed => { + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name.unwrap_or(download_client.unwrap_or_default()) + ))); + lines.push(Line::from(format!( + "Message: {}", + message.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default() + ))); + } + LidarrHistoryEventType::TrackFileDeleted => { + lines.push(Line::from(format!( + "Reason: {}", + reason.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + LidarrHistoryEventType::TrackFileImported => { + lines.push(Line::from(format!( + "Dropped Path: {}", + dropped_path.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Imported Path: {}", + imported_path.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name.unwrap_or(download_client.unwrap_or_default()) + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + LidarrHistoryEventType::TrackFileRenamed => { + lines.push(Line::from(format!( + "Source Path: {}", + source_path.unwrap_or_default() + ))); + lines.push(Line::from(format!("Path: {}", path.unwrap_or_default()))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + LidarrHistoryEventType::TrackFileRetagged => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + LidarrHistoryEventType::AlbumImportIncomplete => { + lines.push(Line::from(format!( + "Status Messages: {}", + status_messages.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default() + ))); + } + _ => { + lines.push(Line::from("No additional details available.".to_owned())); + } + } + + lines +} diff --git a/src/ui/lidarr_ui/lidarr_ui_utils_tests.rs b/src/ui/lidarr_ui/lidarr_ui_utils_tests.rs new file mode 100644 index 0000000..9b2446a --- /dev/null +++ b/src/ui/lidarr_ui/lidarr_ui_utils_tests.rs @@ -0,0 +1,421 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + + use crate::models::lidarr_models::{ + LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; + + #[test] + fn test_create_history_event_details_grabbed() { + let history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Indexer: {}", data.indexer.unwrap())) + ); + assert_eq!( + result[5], + Line::from(format!("NZB Info URL: {}", data.nzb_info_url.unwrap())) + ); + assert_eq!( + result[6], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!( + result[7], + Line::from(format!("Age: {} days", data.age.unwrap())) + ); + assert_eq!( + result[8], + Line::from(format!("Published Date: {}", data.published_date.unwrap())) + ); + assert_eq!( + result[9], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap() + )) + ); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_create_history_event_details_grabbed_uses_download_client_as_fallback() { + let mut history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed); + history_item.data.download_client_name = None; + history_item.data.download_client = Some("Fallback Client".to_owned()); + + let result = create_history_event_details(history_item); + + assert_eq!(result[9], Line::from("Download Client: Fallback Client")); + } + + #[test] + fn test_create_history_event_details_download_imported() { + let history_item = lidarr_history_item(LidarrHistoryEventType::DownloadImported); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_download_failed() { + let history_item = lidarr_history_item(LidarrHistoryEventType::DownloadFailed); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Message: {}", data.message.unwrap())) + ); + assert_eq!( + result[6], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!( + result[7], + Line::from(format!("Indexer: {}", data.indexer.unwrap())) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_track_file_deleted() { + let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileDeleted); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Reason: {}", data.reason.unwrap())) + ); + assert_eq!( + result[5], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_track_file_imported() { + let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileImported); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Dropped Path: {}", data.dropped_path.unwrap())) + ); + assert_eq!( + result[5], + Line::from(format!("Imported Path: {}", data.imported_path.unwrap())) + ); + assert_eq!( + result[6], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap() + )) + ); + assert_eq!( + result[7], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_track_file_renamed() { + let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileRenamed); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Source Path: {}", data.source_path.unwrap())) + ); + assert_eq!( + result[5], + Line::from(format!("Path: {}", data.path.unwrap())) + ); + assert_eq!( + result[6], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 7); + } + + #[test] + fn test_create_history_event_details_track_file_retagged() { + let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileRetagged); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_album_import_incomplete() { + let history_item = lidarr_history_item(LidarrHistoryEventType::AlbumImportIncomplete); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Status Messages: {}", + data.status_messages.unwrap() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Release Group: {}", data.release_group.unwrap())) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_unknown() { + let history_item = lidarr_history_item(LidarrHistoryEventType::Unknown); + let LidarrHistoryItem { + source_title, + event_type, + quality, + date, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text)) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name)) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_with_empty_optional_fields() { + let mut history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed); + history_item.data = LidarrHistoryData::default(); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("Indexer: ")); + assert_eq!(result[5], Line::from("NZB Info URL: ")); + assert_eq!(result[6], Line::from("Release Group: ")); + assert_eq!(result[7], Line::from("Age: 0 days")); + assert!(result[8].to_string().starts_with("Published Date:")); + assert_eq!(result[9], Line::from("Download Client: ")); + } + + fn lidarr_history_item(event_type: LidarrHistoryEventType) -> LidarrHistoryItem { + LidarrHistoryItem { + id: 1, + source_title: "Test Album - Artist Name".into(), + album_id: 100, + artist_id: 10, + event_type, + quality: QualityWrapper { + quality: Quality { + name: "FLAC".to_owned(), + }, + }, + date: Utc::now(), + data: lidarr_history_data(), + } + } + + fn lidarr_history_data() -> LidarrHistoryData { + LidarrHistoryData { + indexer: Some("Test Indexer".to_owned()), + release_group: Some("Test Release Group".to_owned()), + nzb_info_url: Some("https://test.url".to_owned()), + download_client_name: Some("Test Download Client".to_owned()), + download_client: Some("Fallback Download Client".to_owned()), + age: Some("7".to_owned()), + published_date: Some(Utc::now()), + message: Some("Test failure message".to_owned()), + reason: Some("Test deletion reason".to_owned()), + dropped_path: Some("/downloads/completed/album".to_owned()), + imported_path: Some("/music/artist/album".to_owned()), + source_path: Some("/music/artist/old_album_name".to_owned()), + path: Some("/music/artist/new_album_name".to_owned()), + status_messages: Some("Missing tracks: 1, 2, 3".to_owned()), + } + } +} diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index 0bb8614..9dcc2bc 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -5,6 +5,7 @@ use crate::ui::ui_test_utils::test_utils::Utc; use chrono::Duration; #[cfg(not(test))] use chrono::Utc; +use history::HistoryUi; use library::LibraryUi; use ratatui::{ Frame, @@ -35,7 +36,9 @@ use super::{ widgets::loading_block::LoadingBlock, }; +mod history; mod library; +mod lidarr_ui_utils; #[cfg(test)] #[path = "lidarr_ui_tests.rs"] @@ -54,6 +57,7 @@ impl DrawUi for LidarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ => (), } }