diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index b7cee84c..a7aaeb82 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -28,6 +28,15 @@ pub enum ReadarrListCommand { }, #[command(about = "List all authors in your Readarr library")] Authors, + #[command(about = "List all books for the author with the given ID")] + Books { + #[arg( + long, + help = "The Readarr ID of the author whose books you want to list", + required = true + )] + author_id: i64, + }, #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, #[command(about = "Fetch Readarr logs")] @@ -97,6 +106,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Books { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBooks(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::DiskSpace => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 9719b579..a53e3fa0 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -85,6 +85,32 @@ mod tests { assert_eq!(author_command, expected_args); } + #[test] + fn test_list_books_requires_author_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "books"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_books_success() { + let expected_args = ReadarrListCommand::Books { author_id: 1 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "books", "--author-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(books_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(books_command, expected_args); + } + #[test] fn test_list_logs_default_values() { let expected_args = ReadarrListCommand::Logs { @@ -170,6 +196,31 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_books_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBooks(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_books_command = ReadarrListCommand::Books { author_id: 1 }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_books_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_disk_space_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs new file mode 100644 index 00000000..74a347ef --- /dev/null +++ b/src/network/readarr_network/library/books/mod.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use log::info; + +use crate::models::readarr_models::Book; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_books_network_tests.rs"] +mod readarr_books_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_books( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching books for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetBooks(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut books_vec, mut app| { + books_vec.sort_by_key(|a| a.id); + app.data.readarr_data.books.set_items(books_vec); + }) + .await + } +} diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs new file mode 100644 index 00000000..d44c6b2a --- /dev/null +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -0,0 +1,120 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{Book, ReadarrSerdeable}; + 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::{ + BOOK_JSON, stale_book, + }; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_get_books_event() { + let books_json = json!([ + { + "id": 3, + "title": "Third Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-3", + "monitored": true, + "anyEditionOk": true, + "pageCount": 320, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-03-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 1, + "title": "First Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-1", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 2, + "title": "Second Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-2", + "monitored": false, + "anyEditionOk": true, + "pageCount": 128, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-02-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + } + ]); + let response: Vec = serde_json::from_value(books_json.clone()).unwrap(); + let mut sorted_books = response.clone(); + sorted_books.sort_by_key(|book| book.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(books_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Books(books) = result.unwrap() else { + panic!("Expected Books") + }; + + assert_eq!(books, response); + assert_eq!(app.lock().await.data.readarr_data.books.items, sorted_books); + } + + #[tokio::test] + async fn test_handle_get_books_event_failure() { + let stale_books = vec![stale_book()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::(BOOK_JSON).unwrap()])) + .status(500) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.books.set_items(stale_books.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); + } +} diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs index 4f299636..c2fc5060 100644 --- a/src/network/readarr_network/library/mod.rs +++ b/src/network/readarr_network/library/mod.rs @@ -1 +1,2 @@ mod authors; +mod books; diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index a5780f4c..83189d3a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -33,6 +33,7 @@ pub enum ReadarrEvent { EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetAuthorHistory(i64), + GetBooks(i64), GetDiskSpace, GetHostConfig, GetLogs(u64), @@ -65,6 +66,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::GetBooks(_) => "/book", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) | ReadarrEvent::TriggerAutomaticAuthorSearch(_) @@ -130,6 +132,9 @@ impl Network<'_, '_> { .get_readarr_author_history(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBooks(author_id) => { + self.get_books(author_id).await.map(ReadarrSerdeable::from) + } ReadarrEvent::GetDiskSpace => self .get_readarr_diskspace() .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 483b95d9..97240eb9 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,8 +2,9 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, - NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, + MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, + ReadarrHistoryItem, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -47,6 +48,25 @@ pub mod test_utils { "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } }"#; + pub const BOOK_JSON: &str = r#"{ + "id": 1, + "title": "Test Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -65,6 +85,17 @@ pub mod test_utils { } } + pub fn stale_book() -> Book { + Book { + id: 99, + title: "Stale Book".into(), + author_id: 99, + foreign_book_id: "foreign-book-99".to_owned(), + monitored: true, + ..Book::default() + } + } + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { AddAuthorSearchResult { foreign_author_id: "foreign-author-99".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d22a37de..81139c6e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::GetBooks(1), "/book")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")]