feat(readarr): fetch releases for a book and expose them on the CLI

This commit is contained in:
Dark-Alex-17 committed 2026-08-28 14:07:15 -06:00
1 parent 1edceba056
commit 666084a30f
7 files changed
+333 -4

No files matched your search

+17
View File
@@ -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
@@ -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::<NetworkEvent>(
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();
@@ -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<Vec<ReadarrRelease>> {
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<ReadarrRelease>>(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,
@@ -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::<Value>(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::<Value>(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::<Value>(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!([
+6
View File
@@ -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)
}
@@ -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<String> {
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(),
@@ -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"