From c6d729162a6f0f4cf37582eab2fe86c3e7ac109e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 21:22:31 -0600 Subject: [PATCH] feat(readarr): fetch Readarr updates and expose them on the CLI Adds ReadarrEvent::GetUpdates backed by get_readarr_updates, rendering the update history into the Readarr updates view, and surfaces it as 'readarr list updates'. Covers the case where an update record omits the changes key, which the live instance returns for its most recent release. --- src/cli/readarr/list_command_handler.rs | 9 ++ src/cli/readarr/list_command_handler_tests.rs | 27 ++++- src/network/readarr_network/mod.rs | 3 + .../readarr_network/readarr_network_tests.rs | 1 + src/network/readarr_network/system/mod.rs | 84 +++++++++++++- .../system/readarr_system_network_tests.rs | 104 +++++++++++++++++- 6 files changed, 225 insertions(+), 3 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 55c2cb76..33959fbf 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -19,6 +19,8 @@ mod list_command_handler_tests; pub enum ReadarrListCommand { #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, + #[command(about = "List all Readarr updates")] + Updates, } impl From for Command { @@ -55,6 +57,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Updates => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 85568cad..88f4958d 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -23,7 +23,9 @@ mod tests { use super::*; #[rstest] - fn test_list_commands_have_no_arg_requirements(#[values("disk-space")] subcommand: &str) { + fn test_list_commands_have_no_arg_requirements( + #[values("disk-space", "updates")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); assert_ok!(&result); @@ -69,5 +71,28 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_list_updates_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetUpdates.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_updates_command = ReadarrListCommand::Updates; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_updates_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 b61e1875..d8313d75 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -17,6 +17,7 @@ pub enum ReadarrEvent { GetHostConfig, GetSecurityConfig, GetStatus, + GetUpdates, HealthCheck, } @@ -27,6 +28,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", ReadarrEvent::GetStatus => "/system/status", + ReadarrEvent::GetUpdates => "/update", } } } @@ -56,6 +58,7 @@ impl Network<'_, '_> { .await .map(ReadarrSerdeable::from), ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetUpdates => self.get_readarr_updates().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 66899705..5375665c 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -11,6 +11,7 @@ mod tests { #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::GetStatus, "/system/status")] + #[case(ReadarrEvent::GetUpdates, "/update")] 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 index 013c240a..47a8cd3b 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,7 +1,9 @@ -use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus}; +use crate::models::ScrollableText; +use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus, Update}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; +use indoc::formatdoc; use log::info; #[cfg(test)] @@ -73,4 +75,84 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn get_readarr_updates( + &mut self, + ) -> Result> { + info!("Fetching Readarr updates"); + let event = ReadarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + " (Currently Installed)".to_owned() + } else { + " (Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {}{install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.readarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Readarr is {latest_installed} installed + + {updates}" + )); + }) + .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 512bf6b1..958448cf 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -4,11 +4,12 @@ mod tests { use crate::models::readarr_models::ReadarrSerdeable; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, - SecurityConfig, SystemStatus, + SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use chrono::DateTime; + use indoc::formatdoc; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::json; @@ -249,4 +250,105 @@ mod tests { assert_err!(result); assert_is_empty!(app.lock().await.data.readarr_data.version); } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { "new": ["Cool new thing"], "fixed": ["Some bugs killed"] }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { "new": ["Cool new thing (old)", "Other cool new thing (old)"] }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { "fixed": ["Killed bug 1", "Fixed bug 2"] }, + }, + { + "version": "1.0.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = formatdoc!( + "The latest version of Readarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2 + + + 1.0.0 - 2023-04-15 02:02:53 UTC + {line_break}" + ); + let (mock, app, _server) = MockServarrApi::get() + .returns(updates_json) + .build_for(ReadarrEvent::GetUpdates) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Updates(updates) = result.unwrap() else { + panic!("Expected Updates") + }; + + assert_eq!(updates, response); + + assert_str_eq!( + app.lock().await.data.readarr_data.updates.get_text(), + expected_text + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetUpdates) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.updates.get_text()); + } }