diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs index 025cf6d6..e1306c0a 100644 --- a/src/cli/readarr/add_command_handler.rs +++ b/src/cli/readarr/add_command_handler.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use anyhow::Result; -use clap::Subcommand; +use clap::{ArgAction, Subcommand}; use tokio::sync::Mutex; use super::ReadarrCommand; use crate::{ app::App, cli::{CliCommandHandler, Command}, + models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}, network::{NetworkTrait, readarr_network::ReadarrEvent}, }; @@ -17,6 +18,46 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrAddCommand { + #[command(about = "Add a new root folder")] + RootFolder { + #[arg(long, help = "The name of the root folder", required = true)] + name: String, + #[arg(long, help = "The path of the new root folder", required = true)] + root_folder_path: String, + #[arg( + long, + help = "The ID of the default quality profile for authors in this root folder", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the default metadata profile for authors in this root folder", + required = true + )] + metadata_profile_id: i64, + #[arg( + long, + help = "The default monitor option for authors in this root folder", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "The default monitor new items option for authors in this root folder", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Default tag IDs for authors in this root folder", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + }, #[command(about = "Add new tag")] Tag { #[arg(long, help = "The name of the tag to be added", required = true)] @@ -51,6 +92,31 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrAddCommand::RootFolder { + name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + monitor, + monitor_new_items, + tag: tags, + } => { + let add_root_folder_body = AddReadarrRootFolderBody { + name, + path: root_folder_path, + default_quality_profile_id: quality_profile_id, + default_metadata_profile_id: metadata_profile_id, + default_monitor_option: monitor, + default_new_item_monitor_option: monitor_new_items, + default_tags: tags, + tag_input_string: None, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddRootFolder(add_root_folder_body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrAddCommand::Tag { name } => { let resp = self .network diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs index 21ac0c34..ba3ad687 100644 --- a/src/cli/readarr/add_command_handler_tests.rs +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Command, readarr::{ReadarrCommand, add_command_handler::ReadarrAddCommand}, }, + models::readarr_models::{MonitorType, NewItemMonitorType}, }; use pretty_assertions::assert_eq; @@ -26,6 +27,53 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_add_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "add", "root-folder"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_root_folder_success() { + let expected_args = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: "/nfs/test".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![], + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "root-folder", + "--name", + "Books", + "--root-folder-path", + "/nfs/test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(add_command, expected_args); + } + #[test] fn test_add_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "tag"]); @@ -64,13 +112,59 @@ mod tests { use crate::cli::CliCommandHandler; use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use crate::models::Serdeable; - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{ + AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + }; use crate::network::readarr_network::ReadarrEvent; use crate::{ app::App, network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_add_root_folder_command() { + let expected_root_folder_path = "/nfs/test".to_owned(); + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: expected_root_folder_path.clone(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddRootFolder(expected_add_root_folder_body.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_root_folder_command = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: expected_root_folder_path, + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![1, 2], + }; + + let result = + ReadarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_add_tag_command() { let expected_tag_name = "test".to_owned(); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 07a99fec..3e662e80 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,20 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddReadarrRootFolderBody { + pub name: String, + pub path: String, + pub default_quality_profile_id: i64, + pub default_metadata_profile_id: i64, + pub default_monitor_option: MonitorType, + pub default_new_item_monitor_option: NewItemMonitorType, + pub default_tags: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Book { diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index af59fe92..bce1edc4 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,7 @@ use log::info; use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; +use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -16,6 +16,7 @@ mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteTag(i64), GetDiskSpace, @@ -44,7 +45,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", - ReadarrEvent::GetRootFolders => "/rootfolder", + ReadarrEvent::AddRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetTasks => "/system/task", ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", @@ -65,6 +66,10 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::AddRootFolder(add_root_folder_body) => self + .add_readarr_root_folder(add_root_folder_body) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), ReadarrEvent::DeleteTag(tag_id) => self .delete_readarr_tag(tag_id) @@ -225,4 +230,40 @@ impl Network<'_, '_> { .handle_request::<(), ()>(request_props, |_, _| ()) .await } + + pub(in crate::network::readarr_network) async fn extract_and_add_readarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.readarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_readarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .readarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index c20b8d92..8fad713b 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::ReadarrSerdeable; + use crate::app::App; + use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -8,6 +9,8 @@ mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; #[rstest] #[case(ReadarrEvent::GetQueuedEvents, "/command")] @@ -19,6 +22,10 @@ mod tests { #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case( + ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), + "/rootfolder" + )] #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetTasks, "/system/task")] @@ -446,4 +453,55 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.readarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + app_arc.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app_arc); + + assert_eq!( + network.extract_and_add_readarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec_add_missing_tags_first() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "TESTING" })) + .returns(json!({ "id": 3, "label": "testing" })) + .build_for(ReadarrEvent::GetTags) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app_guard = app.lock().await; + app_guard.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let tag_ids_vec = network.extract_and_add_readarr_tag_ids_vec(tags).await; + + mock.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app.lock().await.data.readarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } } diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs index 21c1030c..d683dc09 100644 --- a/src/network/readarr_network/root_folders/mod.rs +++ b/src/network/readarr_network/root_folders/mod.rs @@ -1,14 +1,46 @@ +use crate::models::readarr_models::AddReadarrRootFolderBody; use crate::models::servarr_models::RootFolder; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; -use log::info; +use log::{debug, info}; +use serde_json::Value; #[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 add_readarr_root_folder( + &mut self, + mut add_root_folder_body: AddReadarrRootFolderBody, + ) -> Result { + info!("Adding new root folder to Readarr"); + if let Some(tag_input_str) = add_root_folder_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_root_folder_body.default_tags = tag_ids_vec; + } + let event = ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()); + + debug!("Add root folder body: {add_root_folder_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_root_folder_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( &mut self, ) -> Result> { 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 index b8a696dc..70eb63a8 100644 --- 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 @@ -1,9 +1,12 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::readarr_models::{ + AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + }; use crate::models::servarr_models::RootFolder; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; + use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::json; @@ -17,6 +20,117 @@ mod tests { } } + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: Some("usenet, testing".to_owned()), + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_does_not_overwrite_default_tags_vec_when_tag_input_string_is_none() + { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_failure() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .status(400) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + #[tokio::test] async fn test_handle_get_readarr_root_folders_event() { let root_folders_json = json!([