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.
This commit is contained in:
Dark-Alex-17 committed 2026-08-20 22:32:16 -06:00
1 parent ce8c51b02f
commit 29186ea03c
6 files changed
+236

No files matched your search

+7
View File
@@ -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
@@ -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")]
@@ -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<Vec<RootFolder>> {
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<RootFolder>>(request_props, |root_folders, mut app| {
app.data.readarr_data.root_folders.set_items(root_folders);
})
.await
}
}
@@ -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<RootFolder> = 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()]
);
}
}