feat(readarr): handle the book details tabs and open the edition details popup

Add BookDetailsHandler, covering the editions, history and manual search
tables, the automatic search, manual download and delete book file prompts,
and tab movement across the book details modal.

Selecting an edition builds the EditionDetailsModal client side from the
already loaded row and pushes the popup block. Readarr has no edition detail
endpoint, so there is no network request and no event to dispatch.

The delete book file prompt is guarded on the book files table being
populated. Unlike Lidarr, where a track row carries its own file id, an
edition carries none and the file id comes from a separate table that can be
empty while editions are present.
This commit is contained in:
Dark-Alex-17 committed 2026-09-03 11:43:49 -06:00
1 parent baf74d95e1
commit 14deed6b81
5 files changed
+1601 -4

No files matched your search

@@ -389,7 +389,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandle
}
}
fn releases_sorting_options() -> Vec<SortOption<ReadarrRelease>> {
pub(in crate::handlers::readarr_handlers::library) fn releases_sorting_options()
-> Vec<SortOption<ReadarrRelease>> {
vec![
SortOption {
name: "Source",
@@ -0,0 +1,492 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::readarr_handlers::history::history_sorting_options;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key;
use crate::models::readarr_models::{Edition, Ratings, ReadarrHistoryItem, ReadarrRelease};
use crate::models::servarr_data::readarr::modals::EditionDetailsModal;
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS};
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::models::{Route, ScrollableText};
use crate::network::readarr_network::ReadarrEvent;
use indoc::formatdoc;
use super::author_details_handler::releases_sorting_options;
#[cfg(test)]
#[path = "book_details_handler_tests.rs"]
mod book_details_handler_tests;
pub(in crate::handlers::readarr_handlers) struct BookDetailsHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_readarr_block: ActiveReadarrBlock,
_context: Option<ActiveReadarrBlock>,
}
impl BookDetailsHandler<'_, '_> {
fn extract_book_id(&self) -> i64 {
self.app.data.readarr_data.books.current_selection().id
}
fn extract_book_file_id(&self) -> i64 {
self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_files
.current_selection()
.id
}
fn book_files_are_populated(&self) -> bool {
self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.is_some_and(|modal| !modal.book_files.is_empty())
}
fn editions_are_populated(&self) -> bool {
self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.is_some_and(|modal| !modal.editions.is_empty())
}
fn build_edition_details_modal(&mut self) {
let Edition {
title,
overview,
format,
publisher,
page_count,
release_date,
isbn13,
asin,
ratings,
language,
monitored,
..
} = self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.editions
.current_selection()
.clone();
let format = format.unwrap_or_default();
let language = language.unwrap_or_default();
let publisher = publisher.unwrap_or_default();
let page_count = page_count
.map(|count| count.to_string())
.unwrap_or_default();
let release_date = release_date
.map(|date| date.to_string())
.unwrap_or_default();
let isbn13 = isbn13.unwrap_or_default();
let asin = asin.unwrap_or_default();
let ratings = ratings
.map(|Ratings { votes, value, .. }| format!("{value} ({votes} votes)"))
.unwrap_or_default();
let overview = overview.unwrap_or_default();
let details = formatdoc!(
"
Title: {title}
Format: {format}
Language: {language}
Publisher: {publisher}
Page Count: {page_count}
Release Date: {release_date}
ISBN13: {isbn13}
ASIN: {asin}
Ratings: {ratings}
Monitored: {monitored}
Overview: {overview}
"
);
self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.edition_details_modal = Some(EditionDetailsModal {
edition_details: ScrollableText::with_string(details),
});
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> {
fn handle(&mut self) {
let editions_table_handling_config =
TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into())
.searching_block(ActiveReadarrBlock::SearchEditions.into())
.search_error_block(ActiveReadarrBlock::SearchEditionsError.into())
.search_field_fn(|edition: &Edition| &edition.title);
let book_history_table_handling_config =
TableHandlingConfig::new(ActiveReadarrBlock::BookHistory.into())
.sorting_block(ActiveReadarrBlock::BookHistorySortPrompt.into())
.sort_options(history_sorting_options())
.searching_block(ActiveReadarrBlock::SearchBookHistory.into())
.search_error_block(ActiveReadarrBlock::SearchBookHistoryError.into())
.search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text)
.filtering_block(ActiveReadarrBlock::FilterBookHistory.into())
.filter_error_block(ActiveReadarrBlock::FilterBookHistoryError.into())
.filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text);
let book_releases_table_handling_config =
TableHandlingConfig::new(ActiveReadarrBlock::ManualBookSearch.into())
.sorting_block(ActiveReadarrBlock::ManualBookSearchSortPrompt.into())
.sort_options(releases_sorting_options());
if !handle_table(
self,
|app| {
&mut app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.editions
},
editions_table_handling_config,
) && !handle_table(
self,
|app| {
&mut app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.book_history
},
book_history_table_handling_config,
) && !handle_table(
self,
|app| {
&mut app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.book_releases
},
book_releases_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveReadarrBlock) -> bool {
BOOK_DETAILS_BLOCKS.contains(&active_block)
}
fn ignore_special_keys(&self) -> bool {
self.app.ignore_special_keys_for_textbox_input
}
fn new(
key: Key,
app: &'a mut App<'b>,
active_block: ActiveReadarrBlock,
_context: Option<ActiveReadarrBlock>,
) -> BookDetailsHandler<'a, 'b> {
BookDetailsHandler {
key,
app,
active_readarr_block: active_block,
_context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn is_ready(&self) -> bool {
if self.app.is_loading {
return false;
}
let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else {
return false;
};
match self.active_readarr_block {
ActiveReadarrBlock::BookDetails => !book_details_modal.editions.is_empty(),
ActiveReadarrBlock::BookHistory => !book_details_modal.book_history.is_empty(),
ActiveReadarrBlock::BookFileInfo => !book_details_modal.book_files.is_empty(),
ActiveReadarrBlock::ManualBookSearch => !book_details_modal.book_releases.is_empty(),
_ => true,
}
}
fn handle_scroll_up(&mut self) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {}
fn handle_end(&mut self) {}
fn handle_delete(&mut self) {
if self.active_readarr_block == ActiveReadarrBlock::BookDetails
&& self.book_files_are_populated()
{
self
.app
.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into());
}
}
fn handle_left_right_action(&mut self) {
match self.active_readarr_block {
ActiveReadarrBlock::BookDetails
| ActiveReadarrBlock::BookHistory
| ActiveReadarrBlock::BookFileInfo
| ActiveReadarrBlock::ManualBookSearch => match self.key {
_ if matches_key!(left, self.key) => {
self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.book_details_tabs
.previous();
self.app.pop_and_push_navigation_stack(
self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_details_tabs
.get_active_route(),
);
}
_ if matches_key!(right, self.key) => {
self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.book_details_tabs
.next();
self.app.pop_and_push_navigation_stack(
self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_details_tabs
.get_active_route(),
);
}
_ => (),
},
ActiveReadarrBlock::AutomaticallySearchBookPrompt
| ActiveReadarrBlock::ManualBookSearchConfirmPrompt
| ActiveReadarrBlock::DeleteBookFilePrompt => {
handle_prompt_toggle(self.app, self.key);
}
_ => (),
}
}
fn handle_submit(&mut self) {
match self.active_readarr_block {
ActiveReadarrBlock::BookDetails if self.editions_are_populated() => {
self.build_edition_details_modal();
self
.app
.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
}
ActiveReadarrBlock::BookHistory => self
.app
.push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()),
ActiveReadarrBlock::ManualBookSearch => {
self
.app
.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into());
}
ActiveReadarrBlock::ManualBookSearchConfirmPrompt => {
if self.app.data.readarr_data.prompt_confirm {
let ReadarrRelease {
guid, indexer_id, ..
} = self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_releases
.current_selection()
.clone();
let params = ReleaseDownloadBody { guid, indexer_id };
self.app.data.readarr_data.prompt_confirm_action =
Some(ReadarrEvent::DownloadRelease(params));
}
self.app.pop_navigation_stack();
}
ActiveReadarrBlock::AutomaticallySearchBookPrompt => {
if self.app.data.readarr_data.prompt_confirm {
self.app.data.readarr_data.prompt_confirm_action = Some(
ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()),
);
}
self.app.pop_navigation_stack();
}
ActiveReadarrBlock::DeleteBookFilePrompt => {
if self.app.data.readarr_data.prompt_confirm {
self.app.data.readarr_data.prompt_confirm_action =
Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id()));
}
self.app.pop_navigation_stack();
}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_readarr_block {
ActiveReadarrBlock::BookDetails
| ActiveReadarrBlock::BookFileInfo
| ActiveReadarrBlock::ManualBookSearch => {
self.app.pop_navigation_stack();
self.app.data.readarr_data.book_details_modal = None;
}
ActiveReadarrBlock::BookHistoryDetails => {
self.app.pop_navigation_stack();
}
ActiveReadarrBlock::BookHistory => {
if self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_history
.filtered_items
.is_some()
{
self
.app
.data
.readarr_data
.book_details_modal
.as_mut()
.expect("Book details modal is undefined")
.book_history
.reset_filter();
} else {
self.app.pop_navigation_stack();
self.app.data.readarr_data.book_details_modal = None;
}
}
ActiveReadarrBlock::AutomaticallySearchBookPrompt
| ActiveReadarrBlock::ManualBookSearchConfirmPrompt
| ActiveReadarrBlock::DeleteBookFilePrompt => {
self.app.pop_navigation_stack();
self.app.data.readarr_data.prompt_confirm = false;
}
_ => (),
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_readarr_block {
ActiveReadarrBlock::BookDetails
| ActiveReadarrBlock::BookHistory
| ActiveReadarrBlock::BookFileInfo
| ActiveReadarrBlock::ManualBookSearch => match key {
_ if matches_key!(refresh, key) => {
self
.app
.pop_and_push_navigation_stack(self.active_readarr_block.into());
}
_ if matches_key!(auto_search, key) => {
self
.app
.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into());
}
_ => (),
},
ActiveReadarrBlock::AutomaticallySearchBookPrompt if matches_key!(confirm, key) => {
self.app.data.readarr_data.prompt_confirm = true;
self.app.data.readarr_data.prompt_confirm_action = Some(
ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()),
);
self.app.pop_navigation_stack();
}
ActiveReadarrBlock::DeleteBookFilePrompt if matches_key!(confirm, key) => {
self.app.data.readarr_data.prompt_confirm = true;
self.app.data.readarr_data.prompt_confirm_action =
Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id()));
self.app.pop_navigation_stack();
}
ActiveReadarrBlock::ManualBookSearchConfirmPrompt if matches_key!(confirm, key) => {
self.app.data.readarr_data.prompt_confirm = true;
let ReadarrRelease {
guid, indexer_id, ..
} = self
.app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("Book details modal is undefined")
.book_releases
.current_selection()
.clone();
let params = ReleaseDownloadBody { guid, indexer_id };
self.app.data.readarr_data.prompt_confirm_action =
Some(ReadarrEvent::DownloadRelease(params));
self.app.pop_navigation_stack();
}
_ => (),
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
File diff suppressed because it is too large. Load diff
@@ -14,8 +14,9 @@ mod tests {
use crate::models::HorizontallyScrollableText;
use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus};
use crate::models::servarr_data::readarr::readarr_data::{
AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, LIBRARY_BLOCKS,
AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, LIBRARY_BLOCKS,
};
use crate::test_handler_delegation;
mod test_handle_scroll_up_and_down {
use pretty_assertions::assert_str_eq;
@@ -878,11 +879,21 @@ mod tests {
assert_str_eq!(sort_option.name, "Tags");
}
#[test]
fn test_delegates_book_details_blocks_to_book_details_handler() {
test_handler_delegation!(
LibraryHandler,
ActiveReadarrBlock::AuthorDetails,
ActiveReadarrBlock::BookDetails
);
}
#[test]
fn test_library_handler_accepts() {
let mut library_handler_blocks = Vec::new();
library_handler_blocks.extend(LIBRARY_BLOCKS);
library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS);
library_handler_blocks.extend(BOOK_DETAILS_BLOCKS);
ActiveReadarrBlock::iter().for_each(|readarr_block| {
if library_handler_blocks.contains(&readarr_block) {
+11 -2
View File
@@ -20,8 +20,11 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::models::Route;
mod author_details_handler;
mod book_details_handler;
pub(in crate::handlers::readarr_handlers) use author_details_handler::AuthorDetailsHandler;
pub(in crate::handlers::readarr_handlers) use {
author_details_handler::AuthorDetailsHandler, book_details_handler::BookDetailsHandler,
};
#[cfg(test)]
#[path = "library_handler_tests.rs"]
@@ -63,13 +66,19 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a,
AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context)
.handle();
}
_ if BookDetailsHandler::accepts(self.active_readarr_block) => {
BookDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context)
.handle();
}
_ => self.handle_key_event(),
}
}
}
fn accepts(active_block: ActiveReadarrBlock) -> bool {
AuthorDetailsHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block)
AuthorDetailsHandler::accepts(active_block)
|| BookDetailsHandler::accepts(active_block)
|| LIBRARY_BLOCKS.contains(&active_block)
}
fn ignore_special_keys(&self) -> bool {