From c82006862e32ec2c09d3d39d4beccd5639217988 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 27 Aug 2026 10:43:37 -0600 Subject: [PATCH] feat(readarr): refresh and rescan a Readarr author and expose the command on the CLI Posts the RefreshAuthor command to the command endpoint with the author ID, queueing a metadata refresh and a disk scan for that author. The response is returned verbatim and no app state is mutated. Adds the refresh subcommand group to the Readarr CLI. --- src/cli/readarr/mod.rs | 12 +++ src/cli/readarr/refresh_command_handler.rs | 72 +++++++++++++ .../readarr/refresh_command_handler_tests.rs | 102 ++++++++++++++++++ .../readarr_network/library/authors/mod.rs | 21 ++++ .../authors/readarr_authors_network_tests.rs | 43 ++++++++ src/network/readarr_network/mod.rs | 8 +- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/cli/readarr/refresh_command_handler.rs create mode 100644 src/cli/readarr/refresh_command_handler_tests.rs diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index b33c5597..737ebb4f 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -7,6 +7,7 @@ use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; +use refresh_command_handler::{ReadarrRefreshCommand, ReadarrRefreshCommandHandler}; use tokio::sync::Mutex; use trigger_automatic_search_command_handler::{ ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, @@ -22,6 +23,7 @@ mod delete_command_handler; mod edit_command_handler; mod get_command_handler; mod list_command_handler; +mod refresh_command_handler; mod trigger_automatic_search_command_handler; #[cfg(test)] @@ -55,6 +57,11 @@ pub enum ReadarrCommand { about = "Commands to list attributes from your Readarr instance" )] List(ReadarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Readarr instance" + )] + Refresh(ReadarrRefreshCommand), #[command( subcommand, about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" @@ -144,6 +151,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Refresh(refresh_command) => { + ReadarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } ReadarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { ReadarrTriggerAutomaticSearchCommandHandler::with( self.app, diff --git a/src/cli/readarr/refresh_command_handler.rs b/src/cli/readarr/refresh_command_handler.rs new file mode 100644 index 00000000..723e27c1 --- /dev/null +++ b/src/cli/readarr/refresh_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 = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrRefreshCommand { + #[command(about = "Refresh author data and scan disk for the author with the given ID")] + Author { + #[arg( + long, + help = "The ID of the author to refresh information on and to scan the disk for", + required = true + )] + author_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrRefreshCommand) -> Self { + Command::Readarr(ReadarrCommand::Refresh(value)) + } +} + +pub(super) struct ReadarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand> + for ReadarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrRefreshCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateAndScanAuthor(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/refresh_command_handler_tests.rs b/src/cli/readarr/refresh_command_handler_tests.rs new file mode 100644 index 00000000..0d8d4141 --- /dev/null +++ b/src/cli/readarr/refresh_command_handler_tests.rs @@ -0,0 +1,102 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, refresh_command_handler::ReadarrRefreshCommand}, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_refresh_command_from() { + let command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_refresh_author_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_refresh_author_with_author_id() { + let expected_args = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "refresh", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::Refresh(refresh_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(refresh_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::refresh_command_handler::{ + ReadarrRefreshCommand, ReadarrRefreshCommandHandler, + }; + 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_refresh_author_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::UpdateAndScanAuthor(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 8f763ba0..90fbbd19 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -361,4 +361,25 @@ impl Network<'_, '_> { .handle_request::(request_props, |_, _| ()) .await } + + pub(in crate::network::readarr_network) async fn update_and_scan_author( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::UpdateAndScanAuthor(author_id); + info!("Updating and scanning author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "RefreshAuthor".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 63660863..c8d57e71 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 @@ -800,4 +800,47 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({"name": "RefreshAuthor", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(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 bc83f1f2..fa615678 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -50,6 +50,7 @@ pub enum ReadarrEvent { StartTask(ReadarrTaskName), ToggleAuthorMonitoring(i64), TriggerAutomaticAuthorSearch(i64), + UpdateAndScanAuthor(i64), } impl NetworkResource for ReadarrEvent { @@ -64,7 +65,8 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) - | ReadarrEvent::TriggerAutomaticAuthorSearch(_) => "/command", + | ReadarrEvent::TriggerAutomaticAuthorSearch(_) + | ReadarrEvent::UpdateAndScanAuthor(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", @@ -176,6 +178,10 @@ impl Network<'_, '_> { .trigger_automatic_author_search(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateAndScanAuthor(author_id) => self + .update_and_scan_author(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 0454beba..a67d8217 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -25,6 +25,7 @@ mod tests { #[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] + #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] #[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")]