From 666084a30f63c90d693d597a5b7dd10fb5bae6b2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 28 Aug 2026 14:07:15 -0600 Subject: [PATCH] feat(readarr): fetch releases for a book and expose them on the CLI --- src/cli/readarr/list_command_handler.rs | 17 ++ src/cli/readarr/list_command_handler_tests.rs | 52 ++++++ .../readarr_network/library/books/mod.rs | 32 +++- .../books/readarr_books_network_tests.rs | 155 +++++++++++++++++- src/network/readarr_network/mod.rs | 6 + .../readarr_network_test_utils.rs | 74 ++++++++- .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 333 insertions(+), 4 deletions(-) diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 060ad493..9a27b79b 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -98,6 +98,15 @@ pub enum ReadarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, + #[command(about = "Trigger a manual search for releases of the book with the given ID")] + Releases { + #[arg( + long, + help = "The Readarr ID of the book whose releases you wish to search for", + required = true + )] + book_id: i64, + }, #[command(about = "List all root folders in Readarr")] RootFolders, #[command(about = "List all Readarr tags")] @@ -236,6 +245,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Releases { book_id } => { + println!("Searching for book releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookReleases(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrListCommand::RootFolders => { 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 c7bca4ff..ceb8905a 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -241,6 +241,32 @@ mod tests { assert_eq!(books_command, expected_args); } + #[test] + fn test_list_releases_requires_book_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "releases"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_releases_success() { + let expected_args = ReadarrListCommand::Releases { book_id: 7 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "releases", "--book-id", "7"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(releases_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(releases_command, expected_args); + } + #[test] fn test_list_logs_default_values() { let expected_args = ReadarrListCommand::Logs { @@ -705,6 +731,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_releases_command() { + let expected_book_id = 7; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookReleases(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_releases_command = ReadarrListCommand::Releases { book_id: 7 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_releases_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_root_folders_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs index b5979946..8db329fd 100644 --- a/src/network/readarr_network/library/books/mod.rs +++ b/src/network/readarr_network/library/books/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::readarr_models::{ - Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, + Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::network::readarr_network::ReadarrEvent; @@ -152,6 +152,36 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_book_releases( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching releases for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookReleases(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.book_releases.set_items(release_vec); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_books( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs index ed18d2b7..85c84849 100644 --- a/src/network/readarr_network/library/books/readarr_books_network_tests.rs +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -10,8 +10,9 @@ mod tests { 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::{ - BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, readarr_history_item, stale_book, stale_book_file, - stale_edition, stale_readarr_history_item, + BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, RELEASE_JSON, readarr_history_item, stale_book, + stale_book_file, stale_edition, stale_readarr_history_item, stale_release, torrent_release, + usenet_release, }; use mockito::Matcher; use pretty_assertions::assert_eq; @@ -466,6 +467,156 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_get_book_releases_event() { + let expected_releases = vec![torrent_release(), usenet_release()]; + let releases_json = json!([ + serde_json::from_str::(RELEASE_JSON).unwrap(), + { + "guid": "test-usenet-release-guid", + "protocol": "usenet", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "DrunkenSlug", + "indexerId": 4, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + } + } + ]); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![stale_release()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_empty_book_details_modal() { + let expected_releases = vec![torrent_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_failure() { + let stale_releases = vec![stale_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .status(500) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(stale_releases.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + stale_releases + ); + } + #[tokio::test] async fn test_handle_get_books_event() { let books_json = json!([ diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 113a5a56..e5a8bde4 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -42,6 +42,7 @@ pub enum ReadarrEvent { GetBookEditions(i64), GetBookFiles(i64), GetBookHistory(i64, i64), + GetBookReleases(i64), GetBooks(i64), GetDiskSpace, GetDownloads(u64), @@ -104,6 +105,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", + ReadarrEvent::GetBookReleases(_) => "/release", ReadarrEvent::AddRootFolder(_) | ReadarrEvent::DeleteRootFolder(_) | ReadarrEvent::GetRootFolders => "/rootfolder", @@ -185,6 +187,10 @@ impl Network<'_, '_> { .get_readarr_book_history(author_id, book_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookReleases(book_id) => self + .get_book_releases(book_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetBooks(author_id) => { self.get_books(author_id).await.map(ReadarrSerdeable::from) } diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index c047d5fd..81156c4d 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -4,7 +4,7 @@ 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, + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; use crate::models::servarr_models::{Quality, QualityWrapper, RootFolder}; use chrono::DateTime; @@ -120,6 +120,29 @@ pub mod test_utils { "downloadClient": "NZBGet" }"#; + pub const RELEASE_JSON: &str = r#"{ + "guid": "test-release-guid", + "protocol": "torrent", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "seeders": 2, + "leechers": 1, + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "customFormatScore": 0, + "downloadAllowed": true, + "publishDate": "2014-05-11T14:38:20Z" + }"#; + pub fn stale_author() -> Author { Author { id: 99, @@ -211,6 +234,55 @@ pub mod test_utils { QualityWrapper { quality: quality() } } + pub fn stale_release() -> ReadarrRelease { + ReadarrRelease { + guid: "stale-release-guid".to_owned(), + protocol: "usenet".to_owned(), + title: "Stale Release".into(), + indexer: "Stale Indexer".to_owned(), + indexer_id: 99, + ..ReadarrRelease::default() + } + } + + pub fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + pub fn torrent_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-release-guid".to_owned(), + protocol: "torrent".to_owned(), + age: 4492, + title: "Test Author - Test Book [AZW3]".into(), + author_name: Some("Test Author".to_owned()), + book_title: Some("Test Book".to_owned()), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 313924185, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + quality: quality_wrapper(), + } + } + + pub fn usenet_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-usenet-release-guid".to_owned(), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + indexer_id: 4, + seeders: None, + leechers: None, + ..torrent_release() + } + } + pub fn quality() -> Quality { Quality { name: "AZW3".to_string(), diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 45625eb1..595c4070 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -49,6 +49,7 @@ mod tests { #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(ReadarrEvent::DeleteDownload(1), "/queue")] #[case(ReadarrEvent::GetDownloads(500), "/queue")] + #[case(ReadarrEvent::GetBookReleases(1), "/release")] #[case( ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), "/rootfolder"