feat: scaffold Readarr support across the state, app, handler, UI, and CLI layers

Promote Route::Readarr from a unit stub to a tuple variant and wire Readarr
through every layer, mirroring the Lidarr tree. Readarr appears as a fourth
servarr tab and its library view renders the Authors table.

Adds ReadarrData and its modals, the tick and context clue modules, the
top-level ReadarrHandler, ReadarrUi with the Authors library view, and an
empty ReadarrCommand behind ReadarrCliHandler. The default port is 8787.

Promoting the route variant compiles cleanly without any of the arms that
give it behaviour, so all six of those sites are wired explicitly here:
App::on_tick, handlers::handle_events, handle_prompt_toggle,
ServarrContextClueProvider, ui(), and verify_config_present_for_cli. Each was
checked by removing the arm and confirming its covering assertion then fails.

ReadarrEvent and ReadarrCommand are both declared empty. They gain variants
alongside the network calls and CLI commands that use them, which keeps each
later change to a single function.

Adding the tab changes the rendered tab bar, so 13 existing snapshots
regenerate. The only delta in each is that single tab bar line.

This scaffold adds 24 dead_code warnings, all inside the Readarr tree and
none outside it. They clear as the handlers, UI, network events, and CLI
commands that consume these items land: block arrays awaiting their accepts
implementations and BlockSelectionState seeding, modal and state fields
awaiting their handlers, the CLI handler fields awaiting an inhabited command
enum, and the top-level handler fields awaiting sub-handlers. The count must
strictly decrease from here, so a later increase is a defect rather than
transient noise. The 12 pre-existing collapsible_match warnings are unrelated
and untouched.
This commit is contained in:
Dark-Alex-17 committed 2026-08-20 15:28:49 -06:00
1 parent 4db9b92146
commit 29f043f5c7
64 files changed
+6418 -22

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
use ratatui::{
Frame,
layout::{Constraint, Rect},
widgets::{Cell, Row},
};
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::utils::convert_to_gb;
use crate::{
app::App,
models::{
Route,
readarr_models::{Author, AuthorStatus},
servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS},
},
ui::{
DrawUi,
styles::ManagarrStyle,
utils::{get_width_from_percentage, layout_block_top_border},
},
};
#[cfg(test)]
#[path = "library_ui_tests.rs"]
mod library_ui_tests;
pub(super) struct LibraryUi;
impl DrawUi for LibraryUi {
fn accepts(route: Route) -> bool {
if let Route::Readarr(active_readarr_block, _) = route {
return LIBRARY_BLOCKS.contains(&active_readarr_block);
}
false
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
draw_library(f, app, area);
}
}
fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Readarr(active_readarr_block, _) = app.get_current_route() {
let current_selection = if !app.data.readarr_data.authors.items.is_empty() {
app.data.readarr_data.authors.current_selection().clone()
} else {
Author::default()
};
let quality_profile_map = &app.data.readarr_data.quality_profile_map;
let metadata_profile_map = &app.data.readarr_data.metadata_profile_map;
let tags_map = &app.data.readarr_data.tags_map;
let content = Some(&mut app.data.readarr_data.authors);
let authors_table_row_mapping = |author: &Author| {
author.author_name.scroll_left_or_reset(
get_width_from_percentage(area, 25),
*author == current_selection,
app.should_text_scroll,
);
let monitored = if author.monitored { "🏷" } else { "" };
let author_type = author.author_type.clone().unwrap_or_default();
let size = author
.statistics
.as_ref()
.map_or(0f64, |stats| convert_to_gb(stats.size_on_disk));
let quality_profile = quality_profile_map
.get_by_left(&author.quality_profile_id)
.cloned()
.unwrap_or_default();
let metadata_profile = metadata_profile_map
.get_by_left(&author.metadata_profile_id)
.cloned()
.unwrap_or_default();
let books = author
.statistics
.as_ref()
.map_or(0, |stats| stats.book_count);
let tags = author
.tags
.iter()
.filter_map(|tag_id| {
let id = tag_id.as_i64()?;
tags_map.get_by_left(&id).cloned()
})
.collect::<Vec<_>>()
.join(", ");
decorate_author_row_with_style(
author,
Row::new(vec![
Cell::from(author.author_name.to_string()),
Cell::from(author_type),
Cell::from(author.status.to_display_str()),
Cell::from(quality_profile),
Cell::from(metadata_profile),
Cell::from(books.to_string()),
Cell::from(format!("{size:.2} GB")),
Cell::from(monitored.to_owned()),
Cell::from(tags),
]),
)
};
let authors_table = ManagarrTable::new(content, authors_table_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.sorting(active_readarr_block == ActiveReadarrBlock::AuthorsSortPrompt)
.searching(active_readarr_block == ActiveReadarrBlock::SearchAuthors)
.filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthors)
.search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchAuthorsError)
.filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterAuthorsError)
.headers([
"Name",
"Type",
"Status",
"Quality Profile",
"Metadata Profile",
"Books",
"Size",
"Monitored",
"Tags",
])
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(9),
Constraint::Percentage(8),
Constraint::Percentage(13),
Constraint::Percentage(13),
Constraint::Percentage(7),
Constraint::Percentage(8),
Constraint::Percentage(6),
Constraint::Percentage(11),
]);
if [
ActiveReadarrBlock::SearchAuthors,
ActiveReadarrBlock::FilterAuthors,
]
.contains(&active_readarr_block)
{
authors_table.show_cursor(f, area);
}
f.render_widget(authors_table, area);
}
}
fn decorate_author_row_with_style<'a>(author: &Author, row: Row<'a>) -> Row<'a> {
if !author.monitored {
return row.unmonitored();
}
match author.status {
AuthorStatus::Ended => {
if let Some(ref stats) = author.statistics {
return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 {
row.downloaded()
} else {
row.missing()
};
}
row.indeterminate()
}
AuthorStatus::Continuing => {
if let Some(ref stats) = author.statistics {
return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 {
row.unreleased()
} else {
row.missing()
};
}
row.indeterminate()
}
_ => row.indeterminate(),
}
}