diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 529871b4..b33c5597 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -8,6 +8,9 @@ use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; +use trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, +}; use super::{CliCommandHandler, Command}; use crate::models::readarr_models::ReadarrTaskName; @@ -19,6 +22,7 @@ mod delete_command_handler; mod edit_command_handler; mod get_command_handler; mod list_command_handler; +mod trigger_automatic_search_command_handler; #[cfg(test)] #[path = "readarr_command_tests.rs"] @@ -51,6 +55,11 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command( + subcommand, + about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" + )] + TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), #[command(about = "Search for a new author to add to Readarr")] SearchNewAuthor { #[arg( @@ -135,6 +144,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { + ReadarrTriggerAutomaticSearchCommandHandler::with( + self.app, + trigger_automatic_search_command, + self.network, + ) + .handle() + .await? + } ReadarrCommand::SearchNewAuthor { query } => { let resp = self .network diff --git a/src/cli/readarr/trigger_automatic_search_command_handler.rs b/src/cli/readarr/trigger_automatic_search_command_handler.rs new file mode 100644 index 00000000..4722bc6a --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "trigger_automatic_search_command_handler_tests.rs"] +mod trigger_automatic_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the author with the specified ID")] + Author { + #[arg( + long, + help = "The ID of the author you want to trigger an automatic search for", + required = true + )] + author_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrTriggerAutomaticSearchCommand) -> Self { + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(value)) + } +} + +pub(super) struct ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrTriggerAutomaticSearchCommand> + for ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrTriggerAutomaticSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrTriggerAutomaticSearchCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TriggerAutomaticAuthorSearch(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs new file mode 100644 index 00000000..d678fa52 --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs @@ -0,0 +1,118 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ + ReadarrCommand, + trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand, + }, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_trigger_automatic_search_command_from() { + let command = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_trigger_automatic_author_search_requires_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_author_search_with_author_id() { + let expected_args = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::TriggerAutomaticSearch( + trigger_automatic_search_command, + ))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(trigger_automatic_search_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, + }; + use crate::{app::App, cli::CliCommandHandler}; + use crate::{ + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticAuthorSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = ReadarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 8b14ad16..bb2f10e6 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,16 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrCommandBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub book_ids: Option>, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub struct DeleteParams { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 0c9e8ca6..8f763ba0 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; @@ -340,4 +340,25 @@ impl Network<'_, '_> { } } } + + pub(in crate::network::readarr_network) async fn trigger_automatic_author_search( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::TriggerAutomaticAuthorSearch(author_id); + info!("Searching indexers for author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "AuthorSearch".to_owned(), + author_id: Some(author_id), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .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 index 34199ad8..63660863 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -757,4 +757,47 @@ mod tests { async_toggle_server.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({"name": "AuthorSearch", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index fc2fc0cb..bc83f1f2 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -49,6 +49,7 @@ pub enum ReadarrEvent { SearchNewAuthor(String), StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), + TriggerAutomaticAuthorSearch(i64), } impl NetworkResource for ReadarrEvent { @@ -61,7 +62,9 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", - ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", + ReadarrEvent::GetQueuedEvents + | ReadarrEvent::StartTask(_) + | ReadarrEvent::TriggerAutomaticAuthorSearch(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -169,6 +172,10 @@ impl Network<'_, '_> { .toggle_author_monitoring(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TriggerAutomaticAuthorSearch(author_id) => self + .trigger_automatic_author_search(author_id) + .await + .map(ReadarrSerdeable::from), } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 05e1d7ce..0454beba 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -24,6 +24,7 @@ mod tests { #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] + #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")]