From ab3992c372ba90401d162300579c0769e277062b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:49:28 -0600 Subject: [PATCH] feat(readarr): trigger Readarr tasks and expose the command on the CLI The task name is sent as the shared CommandBody, matching the Radarr, Sonarr, and Lidarr implementations. The response is returned verbatim and no app state is mutated. --- src/cli/readarr/mod.rs | 19 ++++ src/cli/readarr/readarr_command_tests.rs | 86 +++++++++++++++++++ src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 24 +++++- .../system/readarr_system_network_tests.rs | 47 ++++++++++ 6 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 1b8ac8f5..0fdafc66 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -7,6 +7,8 @@ use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; use super::{CliCommandHandler, Command}; +use crate::models::readarr_models::ReadarrTaskName; +use crate::network::readarr_network::ReadarrEvent; use crate::{app::App, network::NetworkTrait}; mod get_command_handler; @@ -28,6 +30,16 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command(about = "Start the specified Readarr task")] + StartTask { + #[arg( + long, + help = "The name of the task to trigger", + value_enum, + required = true + )] + task_name: ReadarrTaskName, + }, } impl From for Command { @@ -67,6 +79,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::StartTask { task_name } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::StartTask(task_name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index f07a7d9b..00cb2582 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -2,6 +2,8 @@ mod tests { mod cli { use clap::CommandFactory; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; use crate::Cli; @@ -11,5 +13,89 @@ mod tests { assert_err!(&result); } + + #[test] + fn test_start_task_requires_task_name() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_start_task_task_name_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_start_task_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "check-health", + ]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::{ReadarrCliHandler, ReadarrCommand}, + }, + models::Serdeable, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_start_task_command() { + let expected_task_name = ReadarrTaskName::CheckHealth; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::StartTask(expected_task_name).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let start_task_command = ReadarrCommand::StartTask { + task_name: ReadarrTaskName::CheckHealth, + }; + + let result = ReadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 6e30e081..3c0b233a 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use log::info; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::ReadarrSerdeable; +use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTaskName}; use crate::network::{Network, RequestMethod}; mod system; @@ -21,11 +21,13 @@ pub enum ReadarrEvent { GetTasks, GetUpdates, HealthCheck, + StartTask(ReadarrTaskName), } impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { + ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -72,6 +74,10 @@ impl Network<'_, '_> { .get_readarr_healthcheck() .await .map(ReadarrSerdeable::from), + ReadarrEvent::StartTask(task_name) => self + .start_readarr_task(task_name) + .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 96ecf18d..d845e79f 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -6,6 +6,7 @@ mod tests { use rstest::rstest; #[rstest] + #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 4f28dcb2..a20bdac8 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::readarr_models::ReadarrTask; +use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; @@ -8,6 +8,7 @@ use crate::network::{Network, RequestMethod}; use anyhow::Result; use indoc::formatdoc; use log::info; +use serde_json::Value; #[cfg(test)] #[path = "readarr_system_network_tests.rs"] @@ -231,4 +232,23 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn start_readarr_task( + &mut self, + task: ReadarrTaskName, + ) -> Result { + let event = ReadarrEvent::StartTask(task); + let task_name = task.to_string(); + info!("Starting Readarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + 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/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 5a50a7c2..871c9b92 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -514,4 +514,51 @@ mod tests { assert_err!(result); assert_is_empty!(app.lock().await.data.readarr_data.updates.get_text()); } + + #[tokio::test] + async fn test_handle_start_readarr_task_event() { + let response = json!({ "test": "test" }); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(response.clone()) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Value(value) = result.unwrap() else { + panic!("Expected Value") + }; + + assert_eq!(value, response); + } + + #[tokio::test] + async fn test_handle_start_readarr_task_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + assert_err!(result); + } }