From e9b9aa3a146f1c9e6f6c63a689bbb2e66f93d2bf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:35:04 -0600 Subject: [PATCH] feat(readarr): trigger automatic author searches and expose the command on the CLI Posts the AuthorSearch command to the command endpoint with the author ID, queueing an indexer search for that author. The response is returned verbatim and no app state is mutated. Introduces ReadarrCommandBody, the request body shared by the author and book command calls. Both ID fields are omitted from the payload when absent rather than sent as null. --- src/cli/readarr/mod.rs | 18 +++ ...rigger_automatic_search_command_handler.rs | 72 +++++++++++ ..._automatic_search_command_handler_tests.rs | 118 ++++++++++++++++++ src/models/readarr_models.rs | 10 ++ .../readarr_network/library/authors/mod.rs | 23 +++- .../authors/readarr_authors_network_tests.rs | 43 +++++++ src/network/readarr_network/mod.rs | 9 +- .../readarr_network/readarr_network_tests.rs | 1 + 8 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/cli/readarr/trigger_automatic_search_command_handler.rs create mode 100644 src/cli/readarr/trigger_automatic_search_command_handler_tests.rs 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")]