From 5c593b3770da97b3952c2b2dc5dfa6789733399c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 1 Sep 2026 16:32:13 -0600 Subject: [PATCH] feat(readarr): fetch the blocklist and expose it on the CLI --- src/cli/readarr/list_command_handler.rs | 9 + src/cli/readarr/list_command_handler_tests.rs | 24 ++ src/network/readarr_network/blocklist/mod.rs | 39 +++ .../readarr_blocklist_network_tests.rs | 226 ++++++++++++++++++ src/network/readarr_network/mod.rs | 7 + .../readarr_network_test_utils.rs | 31 ++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 src/network/readarr_network/blocklist/mod.rs create mode 100644 src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 95a41f90..1e198efc 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -37,6 +37,8 @@ pub enum ReadarrListCommand { }, #[command(about = "List all authors in your Readarr library")] Authors, + #[command(about = "List all items in the Readarr blocklist")] + Blocklist, #[command(about = "List all editions for the book with the given ID")] BookEditions { #[arg( @@ -175,6 +177,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Blocklist => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::BookEditions { book_id } => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index cf1ba14e..b74c1c38 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -27,6 +27,7 @@ mod tests { fn test_list_commands_have_no_arg_requirements( #[values( "authors", + "blocklist", "disk-space", "metadata-profiles", "quality-profiles", @@ -424,6 +425,29 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_blocklist_command = ReadarrListCommand::Blocklist; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_author_history_command() { let expected_author_id = 1; diff --git a/src/network/readarr_network/blocklist/mod.rs b/src/network/readarr_network/blocklist/mod.rs new file mode 100644 index 00000000..1268b0c7 --- /dev/null +++ b/src/network/readarr_network/blocklist/mod.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use log::info; + +use crate::models::Route; +use crate::models::readarr_models::{BlocklistItem, BlocklistResponse}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_blocklist_network_tests.rs"] +mod readarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Readarr blocklist"); + let event = ReadarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec: Vec = blocklist_resp.records; + blocklist_vec.sort_by_key(|item| item.id); + app.data.readarr_data.blocklist.set_items(blocklist_vec); + app.data.readarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs new file mode 100644 index 00000000..e005fae9 --- /dev/null +++ b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs @@ -0,0 +1,226 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{BlocklistItem, BlocklistResponse, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + AUTHOR_JSON, blocklist_item, stale_blocklist_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Number, Value, json}; + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + }, + { + "id": 123, + "authorId": 1007, + "bookIds": [42018], + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let anthology_blocklist_item = BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }; + let z_book_blocklist_item = BlocklistItem { + id: 123, + author_id: 1007, + book_ids: Some(vec![Number::from(42018)]), + source_title: "z book".to_owned(), + ..blocklist_item() + }; + let expected_response = BlocklistResponse { + records: vec![ + anthology_blocklist_item.clone(), + z_book_blocklist_item.clone(), + ], + }; + let mut expected_blocklist_items = vec![z_book_blocklist_item, anthology_blocklist_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist_items.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(vec![stale_blocklist_item()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!( + app.data.readarr_data.blocklist.items, + expected_blocklist_items + ); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let expected_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + app.push_navigation_stack(ActiveReadarrBlock::BlocklistSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.blocklist.items, stale_blocklist_items); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_failure() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .status(500) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.blocklist.items, + stale_blocklist_items + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 8a1eadb7..17c142e5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -10,6 +10,7 @@ use crate::models::readarr_models::{ use crate::models::servarr_models::{MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag}; use crate::network::{Network, RequestMethod}; +mod blocklist; mod downloads; mod history; mod library; @@ -40,6 +41,7 @@ pub enum ReadarrEvent { GetAuthorDetails(i64), GetAuthorHistory(i64), GetAuthorReleases(i64), + GetBlocklist, GetBookDetails(i64), GetBookEditions(i64), GetBookFiles(i64), @@ -84,6 +86,7 @@ impl NetworkResource for ReadarrEvent { | ReadarrEvent::ListAuthors | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", ReadarrEvent::DeleteBook(_) | ReadarrEvent::GetBookDetails(_) | ReadarrEvent::GetBooks(_) @@ -183,6 +186,10 @@ impl Network<'_, '_> { .get_author_releases(author_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBlocklist => self + .get_readarr_blocklist() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBookDetails(book_id) => self .get_book_details(book_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 81156c4d..7bdee11d 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,9 +2,10 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, Book, BookFile, - DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, - ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, + Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, + NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + ReadarrRelease, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -234,6 +235,30 @@ pub mod test_utils { QualityWrapper { quality: quality() } } + pub fn stale_blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 99, + author_id: 99, + source_title: "Stale source title".to_owned(), + ..BlocklistItem::default() + } + } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + author_id: 1, + book_ids: Some(vec![Number::from(1)]), + source_title: "Test source title".to_owned(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "test message".to_owned(), + author: serde_json::from_str(AUTHOR_JSON).unwrap(), + } + } + pub fn stale_release() -> ReadarrRelease { ReadarrRelease { guid: "stale-release-guid".to_owned(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index d4fa1326..5f3bdd5e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -22,6 +22,7 @@ mod tests { #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] #[case(ReadarrEvent::GetBookDetails(1), "/book")] #[case(ReadarrEvent::GetBooks(1), "/book")]