From d8f171ec6fadbf70b28b40b7df6dbead8aa2d6d4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:47:40 -0600 Subject: [PATCH] feat(readarr): fetch the Readarr system status and expose it on the CLI Add ReadarrEvent::GetStatus, mapped to GET /system/status, and get_readarr_status in the new readarr_network/system module. It stores the version and start time on ReadarrData so the system view and the tick refresh can read them. Surface it as `managarr readarr get system-status`, matching the Radarr, Sonarr, and Lidarr spelling of the same call. A `list status` spelling would have made the same command a different verb on the fourth servarr, which is a difference the user would have to remember for no benefit. Readarr instances answer /system/status with thirty keys; SystemStatus models the two that are used and serde ignores the rest, as it does for the other three servarrs. This is the first ReadarrCommand variant, so ReadarrCliHandler now reads its app and network fields. --- src/cli/readarr/get_command_handler.rs | 63 ++++++++++++++++ src/cli/readarr/get_command_handler_tests.rs | 72 +++++++++++++++++++ src/cli/readarr/mod.rs | 21 +++++- src/network/readarr_network/mod.rs | 5 ++ .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 29 ++++++++ .../system/readarr_system_network_tests.rs | 59 +++++++++++++++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/cli/readarr/get_command_handler.rs create mode 100644 src/cli/readarr/get_command_handler_tests.rs create mode 100644 src/network/readarr_network/system/mod.rs create mode 100644 src/network/readarr_network/system/readarr_system_network_tests.rs diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs new file mode 100644 index 00000000..4362793e --- /dev/null +++ b/src/cli/readarr/get_command_handler.rs @@ -0,0 +1,63 @@ +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 = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrGetCommand { + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: ReadarrGetCommand) -> Self { + Command::Readarr(ReadarrCommand::Get(value)) + } +} + +pub(super) struct ReadarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrGetCommand::SystemStatus => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs new file mode 100644 index 00000000..822123bb --- /dev/null +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, get_command_handler::ReadarrGetCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_get_command_from() { + let command = ReadarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Get(command))); + } + + mod cli { + use super::*; + use rstest::rstest; + + #[rstest] + fn test_get_commands_have_no_arg_requirements(#[values("system-status")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "get", subcommand]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}, + }, + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = ReadarrGetCommand::SystemStatus; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 7797af94..1bd27875 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -2,17 +2,26 @@ use std::sync::Arc; use anyhow::Result; use clap::Subcommand; +use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use tokio::sync::Mutex; use super::{CliCommandHandler, Command}; use crate::{app::App, network::NetworkTrait}; +mod get_command_handler; + #[cfg(test)] #[path = "readarr_command_tests.rs"] mod readarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -pub enum ReadarrCommand {} +pub enum ReadarrCommand { + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Readarr instance" + )] + Get(ReadarrGetCommand), +} impl From for Command { fn from(readarr_command: ReadarrCommand) -> Command { @@ -40,6 +49,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, } async fn handle(self) -> Result { - match self.command {} + let result = match self.command { + ReadarrCommand::Get(get_command) => { + ReadarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } + }; + + Ok(result) } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index b47a0032..7be4c816 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -5,12 +5,15 @@ use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::ReadarrSerdeable; use crate::network::{Network, RequestMethod}; +mod system; + #[cfg(test)] #[path = "readarr_network_tests.rs"] mod readarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + GetStatus, HealthCheck, } @@ -18,6 +21,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetStatus => "/system/status", } } } @@ -34,6 +38,7 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), ReadarrEvent::HealthCheck => self .get_readarr_healthcheck() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 699ebd89..f20aa015 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -7,6 +7,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs new file mode 100644 index 00000000..1ac5a65b --- /dev/null +++ b/src/network/readarr_network/system/mod.rs @@ -0,0 +1,29 @@ +use crate::models::servarr_models::SystemStatus; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "readarr_system_network_tests.rs"] +mod readarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_status( + &mut self, + ) -> Result { + info!("Fetching Readarr system status"); + let event = ReadarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.readarr_data.version = system_status.version; + app.data.readarr_data.start_time = system_status.start_time; + }) + .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 new file mode 100644 index 00000000..dc9de9c6 --- /dev/null +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -0,0 +1,59 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::SystemStatus; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_readarr_status_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "version": "0.4.20.129", + "startTime": "2026-08-17T03:55:02Z" + })) + .build_for(ReadarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2026-08-17T03:55:02Z").unwrap()); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + + let ReadarrSerdeable::SystemStatus(status) = result.unwrap() else { + panic!("Expected SystemStatus") + }; + + assert_eq!( + status, + SystemStatus { + version: "0.4.20.129".to_owned(), + start_time: date_time + } + ); + assert_str_eq!(app.lock().await.data.readarr_data.version, "0.4.20.129"); + assert_eq!(app.lock().await.data.readarr_data.start_time, date_time); + } + + #[tokio::test] + async fn test_handle_get_readarr_status_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.version); + } +}