From 84eba9beacb7ffe775a38a62285ff627c597a742 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 26 Aug 2026 11:41:42 -0600 Subject: [PATCH] feat(readarr): fetch Readarr authors and expose them on the CLI Add ReadarrEvent::ListAuthors, mapping to GET /author, and wire it up as `managarr readarr list authors`. This is the first Readarr event that touches the library rather than configuration, so it creates the library/authors module tree mirroring Lidarr's library/artists layout. list_authors mirrors Lidarr's list_artists, including the sort-prompt route guard: a response landing while the user has the sort prompt open must not clobber their in-progress sort selection. Both directions of that guard are covered by tests. --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 +++ .../readarr_network/library/authors/mod.rs | 36 ++++ .../authors/readarr_authors_network_tests.rs | 157 ++++++++++++++++++ src/network/readarr_network/library/mod.rs | 1 + src/network/readarr_network/mod.rs | 4 + .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 232 insertions(+) create mode 100644 src/network/readarr_network/library/authors/mod.rs create mode 100644 src/network/readarr_network/library/authors/readarr_authors_network_tests.rs create mode 100644 src/network/readarr_network/library/mod.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 1723ba16..2ddf9180 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -17,6 +17,8 @@ mod list_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrListCommand { + #[command(about = "List all authors in your Readarr library")] + Authors, #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, #[command(about = "Fetch Readarr logs")] @@ -72,6 +74,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman async fn handle(self) -> Result { let result = match self.command { + ReadarrListCommand::Authors => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ListAuthors.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 14c3e2ee..247d7d34 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -26,6 +26,7 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( #[values( + "authors", "disk-space", "metadata-profiles", "quality-profiles", @@ -87,6 +88,29 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; + #[tokio::test] + async fn test_handle_list_authors_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ListAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_authors_command = ReadarrListCommand::Authors; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_authors_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/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs new file mode 100644 index 00000000..65d1a140 --- /dev/null +++ b/src/network/readarr_network/library/authors/mod.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::Author; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_authors_network_tests.rs"] +mod readarr_authors_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn list_authors(&mut self) -> Result> { + info!("Fetching Readarr authors"); + let event = ReadarrEvent::ListAuthors; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut authors_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::AuthorsSortPrompt, _) + ) { + authors_vec.sort_by_key(|a| a.id); + app.data.readarr_data.authors.set_items(authors_vec); + app.data.readarr_data.authors.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs new file mode 100644 index 00000000..83ddf5e4 --- /dev/null +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -0,0 +1,157 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{Author, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_list_authors_event() { + let authors_json = json!([ + { + "id": 2, + "authorName": "Zeta Author", + "foreignAuthorId": "foreign-author-2", + "status": "continuing", + "overview": "An overview of Zeta Author", + "path": "/nfs/books/Zeta Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + }, + { + "id": 1, + "authorName": "Alpha Author", + "foreignAuthorId": "foreign-author-1", + "status": "continuing", + "overview": "An overview of Alpha Author", + "path": "/nfs/books/Alpha Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + } + ]); + let response: Vec = serde_json::from_value(authors_json.clone()).unwrap(); + let mut sorted_authors = response.clone(); + sorted_authors.sort_by_key(|author| author.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Authors(authors) = result.unwrap() else { + panic!("Expected Authors") + }; + + assert_eq!(authors, response); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + sorted_authors + ); + } + + #[tokio::test] + async fn test_handle_list_authors_event_no_op_when_user_is_selecting_sort_options() { + let authors_json = json!([{ + "id": 1, + "authorName": "Alpha Author", + "foreignAuthorId": "foreign-author-1", + "status": "continuing", + "path": "/nfs/books/Alpha Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }]); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_ok!(result); + assert_is_empty!(app.lock().await.data.readarr_data.authors); + } + + #[tokio::test] + async fn test_handle_list_authors_event_failure() { + let stale_authors: Vec = serde_json::from_value(json!([{ + "id": 99, + "authorName": "Stale Author", + "foreignAuthorId": "foreign-author-99", + "status": "continuing", + "path": "/nfs/books/Stale Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }])) + .unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .authors + .set_items(stale_authors.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + stale_authors + ); + } +} diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs new file mode 100644 index 00000000..4f299636 --- /dev/null +++ b/src/network/readarr_network/library/mod.rs @@ -0,0 +1 @@ +mod authors; diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 3fc0878c..2bc0dafa 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -7,6 +7,7 @@ use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod library; mod root_folders; mod system; @@ -33,12 +34,14 @@ pub enum ReadarrEvent { GetTasks, GetUpdates, HealthCheck, + ListAuthors, StartTask(ReadarrTaskName), } impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::ListAuthors => "/author", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", @@ -122,6 +125,7 @@ impl Network<'_, '_> { .get_readarr_healthcheck() .await .map(ReadarrSerdeable::from), + ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), ReadarrEvent::StartTask(task_name) => self .start_readarr_task(task_name) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 884fb639..8bf06a17 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -13,6 +13,7 @@ mod tests { use tokio::sync::Mutex; #[rstest] + #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")]