From 29186ea03c1217774a5c09dc57f9e438b3897020 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 22:32:16 -0600 Subject: [PATCH] feat(readarr): fetch Readarr root folders and expose them on the CLI Adds the GetRootFolders event backed by GET /rootfolder, storing the result on the Readarr root folders table, and surfaces it as "managarr readarr list root-folders". Root folders reuse the shared RootFolder model. Readarr omits the unmappedFolders key that the other servarrs return, and the field is optional, so it deserializes to None without changing the shared type. Only the read path is included here. Adding and deleting root folders mutate the library layout, so they are left for separate work. --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 +++ src/network/readarr_network/mod.rs | 7 + .../readarr_network/readarr_network_tests.rs | 1 + .../readarr_network/root_folders/mod.rs | 28 +++ .../readarr_root_folders_network_tests.rs | 167 ++++++++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 src/network/readarr_network/root_folders/mod.rs create mode 100644 src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 82c0777f..1723ba16 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -35,6 +35,8 @@ pub enum ReadarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, + #[command(about = "List all root folders in Readarr")] + RootFolders, #[command(about = "List all Readarr tags")] Tags, #[command(about = "List all Readarr tasks")] @@ -115,6 +117,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::RootFolders => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetRootFolders.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::Tags => { 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 4b0e45c9..14c3e2ee 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -30,6 +30,7 @@ mod tests { "metadata-profiles", "quality-profiles", "queued-events", + "root-folders", "tags", "tasks", "updates" @@ -242,6 +243,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_root_folders_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetRootFolders.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_root_folders_command = ReadarrListCommand::RootFolders; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_root_folders_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_tags_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 91b15cd0..d895d059 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -6,6 +6,7 @@ use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod root_folders; mod system; #[cfg(test)] @@ -20,6 +21,7 @@ pub enum ReadarrEvent { GetMetadataProfiles, GetQualityProfiles, GetQueuedEvents, + GetRootFolders, GetSecurityConfig, GetStatus, GetTags, @@ -39,6 +41,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", + ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::GetTags => "/tag", @@ -83,6 +86,10 @@ impl Network<'_, '_> { .get_queued_readarr_events() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetRootFolders => self + .get_readarr_root_folders() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetSecurityConfig => self .get_readarr_security_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 2f34d13a..427f01a1 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -19,6 +19,7 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] #[case(ReadarrEvent::GetTags, "/tag")] diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs new file mode 100644 index 00000000..21c1030c --- /dev/null +++ b/src/network/readarr_network/root_folders/mod.rs @@ -0,0 +1,28 @@ +use crate::models::servarr_models::RootFolder; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "readarr_root_folders_network_tests.rs"] +mod readarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Readarr root folders"); + let event = ReadarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.readarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs new file mode 100644 index 00000000..b8a696dc --- /dev/null +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -0,0 +1,167 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::RootFolder; + 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; + + fn stale_root_folder() -> RootFolder { + RootFolder { + id: 99, + path: "/stale".to_owned(), + accessible: false, + free_space: 1, + unmapped_folders: None, + } + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event() { + let root_folders_json = json!([ + { + "name": "Books", + "path": "/nfs/books", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 1 + }, + { + "name": "Audiobooks", + "path": "/nfs/audiobooks", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 2, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 2 + } + ]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let expected_root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_eq!(root_folders, response); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + expected_root_folders + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_is_empty!(root_folders); + assert_is_empty!(app.lock().await.data.readarr_data.root_folders); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + vec![stale_root_folder()] + ); + } +}