diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 5782e6a5..060ad493 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -77,6 +77,11 @@ pub enum ReadarrListCommand { #[arg(long, help = "How many downloads to fetch", default_value_t = 500)] count: u64, }, + #[command(about = "Fetch all Readarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, #[command(about = "Fetch Readarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -186,6 +191,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::History { events } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetHistory(events).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index c230e6b6..c7bca4ff 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -66,6 +66,15 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); } + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "history", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + #[test] fn test_list_author_history_requires_author_id() { let result = @@ -283,6 +292,35 @@ mod tests { }; assert_eq!(downloads_command, expected_args); } + + #[test] + fn test_list_history_default_values() { + let expected_args = ReadarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "history"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } + + #[test] + fn test_list_history_success() { + let expected_args = ReadarrListCommand::History { events: 1000 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "history", "--events", "1000"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } } mod handler { @@ -508,6 +546,32 @@ 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::( + ReadarrEvent::GetHistory(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_history_command = ReadarrListCommand::History { events: 1000 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/network/readarr_network/history/mod.rs b/src/network/readarr_network/history/mod.rs new file mode 100644 index 00000000..8d3c2247 --- /dev/null +++ b/src/network/readarr_network/history/mod.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryWrapper; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_history_network_tests.rs"] +mod readarr_history_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_history( + &mut self, + events: u64, + ) -> Result { + info!("Fetching all Readarr history events"); + let event = ReadarrEvent::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::<(), ReadarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by_key(|item| item.id); + app.data.readarr_data.history.set_items(history_vec); + app.data.readarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/history/readarr_history_network_tests.rs b/src/network/readarr_network/history/readarr_history_network_tests.rs new file mode 100644 index 00000000..666f7286 --- /dev/null +++ b/src/network/readarr_network/history/readarr_history_network_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrSerdeable, + }; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + readarr_history_item, stale_readarr_history_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let anthology_history_item = ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }; + let z_book_history_item = ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }; + let expected_response = ReadarrHistoryWrapper { + records: vec![anthology_history_item.clone(), z_book_history_item.clone()], + }; + let mut expected_history_items = vec![z_book_history_item, anthology_history_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + 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 + .readarr_data + .history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.history.items, expected_history_items); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let expected_response = ReadarrHistoryWrapper { + records: vec![ + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + app.push_navigation_stack(ActiveReadarrBlock::HistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_is_empty!(app.data.readarr_data.history); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_empty_response() { + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({"records": []})) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_is_empty!(history.records); + assert_is_empty!(app.lock().await.data.readarr_data.history); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_failure() { + let history_json = json!({"records": [{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.history.items, + stale_history_items + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b8613818..229c1c75 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -11,6 +11,7 @@ use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod downloads; +mod history; mod library; mod root_folders; mod system; @@ -44,6 +45,7 @@ pub enum ReadarrEvent { GetBooks(i64), GetDiskSpace, GetDownloads(u64), + GetHistory(u64), GetHostConfig, GetLogs(u64), GetMetadataProfiles, @@ -94,6 +96,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetBookEditions(_) => "/edition", ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetHistory(_) => "/history", ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", @@ -191,6 +194,10 @@ impl Network<'_, '_> { .get_readarr_downloads(count) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetHistory(events) => self + .get_readarr_history(events) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetHostConfig => self .get_readarr_host_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 18fd34b3..062122d1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -40,6 +40,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetBookEditions(1), "/edition")] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetHistory(500), "/history")] #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] #[case(ReadarrEvent::GetLogs(500), "/log")]