feat(readarr): search for new Readarr authors and expose the command on the CLI

This commit is contained in:
Dark-Alex-17 committed 2026-08-26 16:57:26 -06:00
1 parent 5042dbdb3a
commit 9238c1b9d2
7 files changed
+244 -4

No files matched your search

+16
View File
@@ -44,6 +44,15 @@ pub enum ReadarrCommand {
about = "Commands to list attributes from your Readarr instance" about = "Commands to list attributes from your Readarr instance"
)] )]
List(ReadarrListCommand), List(ReadarrListCommand),
#[command(about = "Search for a new author to add to Readarr")]
SearchNewAuthor {
#[arg(
long,
help = "The name of the author you want to search for",
required = true
)]
query: String,
},
#[command(about = "Start the specified Readarr task")] #[command(about = "Start the specified Readarr task")]
StartTask { StartTask {
#[arg( #[arg(
@@ -103,6 +112,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a,
.handle() .handle()
.await? .await?
} }
ReadarrCommand::SearchNewAuthor { query } => {
let resp = self
.network
.handle_network_event(ReadarrEvent::SearchNewAuthor(query).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
ReadarrCommand::StartTask { task_name } => { ReadarrCommand::StartTask { task_name } => {
let resp = self let resp = self
.network .network
+52
View File
@@ -14,6 +14,31 @@ mod tests {
assert_err!(&result); assert_err!(&result);
} }
#[test]
fn test_search_new_author_requires_query() {
let result =
Cli::command().try_get_matches_from(["managarr", "readarr", "search-new-author"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_search_new_author_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"readarr",
"search-new-author",
"--query",
"test query",
]);
assert_ok!(&result);
}
#[test] #[test]
fn test_start_task_requires_task_name() { fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]);
@@ -71,6 +96,33 @@ mod tests {
network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent},
}; };
#[tokio::test]
async fn test_search_new_author_command() {
let expected_query = "test author".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::SearchNewAuthor(expected_query.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let search_new_author_command = ReadarrCommand::SearchNewAuthor {
query: expected_query,
};
let result = ReadarrCliHandler::with(&app_arc, search_new_author_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test] #[tokio::test]
async fn test_start_task_command() { async fn test_start_task_command() {
let expected_task_name = ReadarrTaskName::CheckHealth; let expected_task_name = ReadarrTaskName::CheckHealth;
@@ -2,10 +2,12 @@ use anyhow::Result;
use log::info; use log::info;
use crate::models::Route; use crate::models::Route;
use crate::models::readarr_models::Author; use crate::models::readarr_models::{AddAuthorSearchResult, Author};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::ReadarrEvent;
use crate::network::{Network, RequestMethod}; use crate::network::{Network, RequestMethod};
use urlencoding::encode;
#[cfg(test)] #[cfg(test)]
#[path = "readarr_authors_network_tests.rs"] #[path = "readarr_authors_network_tests.rs"]
@@ -55,4 +57,44 @@ impl Network<'_, '_> {
}) })
.await .await
} }
pub(in crate::network::readarr_network) async fn search_author(
&mut self,
query: String,
) -> Result<Vec<AddAuthorSearchResult>> {
info!("Searching for author: {query}");
let event = ReadarrEvent::SearchNewAuthor(String::new());
let request_props = self
.request_props_from(
event,
RequestMethod::Get,
None::<()>,
None,
Some(format!("term={}", encode(&query))),
)
.await;
let result = self
.handle_request::<(), Vec<AddAuthorSearchResult>>(request_props, |author_vec, mut app| {
if author_vec.is_empty() {
app.pop_and_push_navigation_stack(ActiveReadarrBlock::AddAuthorEmptySearchResults.into());
} else if let Some(add_searched_authors) =
app.data.readarr_data.add_searched_authors.as_mut()
{
add_searched_authors.set_items(author_vec);
} else {
let mut add_searched_authors = StatefulTable::default();
add_searched_authors.set_items(author_vec);
app.data.readarr_data.add_searched_authors = Some(add_searched_authors);
}
})
.await;
if result.is_err() {
self.app.lock().await.data.readarr_data.add_searched_authors = Some(StatefulTable::default());
}
result
}
} }
@@ -1,11 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::readarr_models::{Author, ReadarrSerdeable}; use crate::models::readarr_models::{AddAuthorSearchResult, Author, ReadarrSerdeable};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::ReadarrEvent;
use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ use crate::network::readarr_network::readarr_network_test_utils::test_utils::{
AUTHOR_JSON, stale_author, ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, stale_add_author_search_result, stale_author,
}; };
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -187,4 +188,104 @@ mod tests {
stale_authors stale_authors
); );
} }
#[tokio::test]
async fn test_handle_search_new_author_event() {
let search_results_json =
json!([serde_json::from_str::<Value>(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()]);
let expected_results: Vec<AddAuthorSearchResult> =
serde_json::from_value(search_results_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(search_results_json)
.query("term=test%20%26%20author")
.build_for(ReadarrEvent::SearchNewAuthor("test & author".to_owned()))
.await;
app.lock().await.server_tabs.set_index(3);
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::SearchNewAuthor("test & author".to_owned()))
.await;
mock.assert_async().await;
let ReadarrSerdeable::AddAuthorSearchResults(search_results) = result.unwrap() else {
panic!("Expected AddAuthorSearchResults")
};
assert_eq!(search_results, expected_results);
assert_eq!(
app
.lock()
.await
.data
.readarr_data
.add_searched_authors
.as_ref()
.unwrap()
.items,
expected_results
);
}
#[tokio::test]
async fn test_handle_search_new_author_event_navigates_to_empty_results_when_empty() {
let (mock, app, _server) = MockServarrApi::get()
.returns(json!([]))
.query("term=nonexistent")
.build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned()))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into());
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned()))
.await;
mock.assert_async().await;
assert_ok!(result);
let app = app.lock().await;
assert_none!(&app.data.readarr_data.add_searched_authors);
assert_eq!(
app.get_current_route(),
ActiveReadarrBlock::AddAuthorEmptySearchResults.into()
);
}
#[tokio::test]
async fn test_handle_search_new_author_event_sets_empty_table_on_api_error() {
let (mock, app, _server) = MockServarrApi::get()
.returns(json!([serde_json::from_str::<Value>(
ADD_AUTHOR_SEARCH_RESULT_JSON
)
.unwrap()]))
.status(500)
.query("term=nonexistent")
.build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned()))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
let mut stale_table = StatefulTable::default();
stale_table.set_items(vec![stale_add_author_search_result()]);
app.data.readarr_data.add_searched_authors = Some(stale_table);
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned()))
.await;
mock.assert_async().await;
assert_err!(result);
let app = app.lock().await;
assert_some!(&app.data.readarr_data.add_searched_authors);
assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap());
}
} }
+5
View File
@@ -40,6 +40,7 @@ pub enum ReadarrEvent {
GetUpdates, GetUpdates,
HealthCheck, HealthCheck,
ListAuthors, ListAuthors,
SearchNewAuthor(String),
StartTask(ReadarrTaskName), StartTask(ReadarrTaskName),
} }
@@ -47,6 +48,7 @@ impl NetworkResource for ReadarrEvent {
fn resource(&self) -> &'static str { fn resource(&self) -> &'static str {
match &self { match &self {
ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author",
ReadarrEvent::SearchNewAuthor(_) => "/author/lookup",
ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command",
ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host",
ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetDiskSpace => "/diskspace",
@@ -135,6 +137,9 @@ impl Network<'_, '_> {
.await .await
.map(ReadarrSerdeable::from), .map(ReadarrSerdeable::from),
ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from),
ReadarrEvent::SearchNewAuthor(query) => {
self.search_author(query).await.map(ReadarrSerdeable::from)
}
ReadarrEvent::StartTask(task_name) => self ReadarrEvent::StartTask(task_name) => self
.start_readarr_task(task_name) .start_readarr_task(task_name)
.await .await
@@ -1,7 +1,9 @@
#[cfg(test)] #[cfg(test)]
#[allow(dead_code)] #[allow(dead_code)]
pub mod test_utils { pub mod test_utils {
use crate::models::readarr_models::{Author, AuthorStatus, NewItemMonitorType}; use crate::models::readarr_models::{
AddAuthorSearchResult, Author, AuthorStatus, NewItemMonitorType,
};
use crate::models::servarr_models::RootFolder; use crate::models::servarr_models::RootFolder;
pub const AUTHOR_JSON: &str = r#"{ pub const AUTHOR_JSON: &str = r#"{
@@ -31,6 +33,18 @@ pub mod test_utils {
} }
}"#; }"#;
pub const ADD_AUTHOR_SEARCH_RESULT_JSON: &str = r#"{
"foreignAuthorId": "test-foreign-id",
"authorName": "Test Author",
"status": "continuing",
"ended": false,
"overview": "some interesting description of the author",
"authorType": "Person",
"disambiguation": "American novelist",
"genres": ["science fiction"],
"ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }
}"#;
pub fn stale_author() -> Author { pub fn stale_author() -> Author {
Author { Author {
id: 99, id: 99,
@@ -49,6 +63,15 @@ pub mod test_utils {
} }
} }
pub fn stale_add_author_search_result() -> AddAuthorSearchResult {
AddAuthorSearchResult {
foreign_author_id: "foreign-author-99".to_owned(),
author_name: "Stale Author".into(),
status: AuthorStatus::Continuing,
..AddAuthorSearchResult::default()
}
}
pub fn stale_root_folder() -> RootFolder { pub fn stale_root_folder() -> RootFolder {
RootFolder { RootFolder {
id: 99, id: 99,
@@ -15,6 +15,7 @@ mod tests {
#[rstest] #[rstest]
#[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")]
#[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ListAuthors, "/author")]
#[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")]
#[case(ReadarrEvent::GetQueuedEvents, "/command")] #[case(ReadarrEvent::GetQueuedEvents, "/command")]
#[case(ReadarrEvent::StartTask(Default::default()), "/command")] #[case(ReadarrEvent::StartTask(Default::default()), "/command")]
#[case(ReadarrEvent::GetHostConfig, "/config/host")] #[case(ReadarrEvent::GetHostConfig, "/config/host")]