From 91c2613e659cfa6aaee7a906055f3537ad227f2e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 4 Sep 2026 00:39:33 -0600 Subject: [PATCH] feat(readarr): add the downloads UI Render the Readarr downloads table with the cancel download prompt and the update downloads prompt, and dispatch it from ReadarrUi so the tab is reachable. The snapshot suite covers every block in DOWNLOADS_BLOCKS plus loading and empty states. A second set of snapshots seeds a two row fixture and selects the second row. Its rows deliberately have different completion ratios, so that inverting the percentage calculation is caught rather than cancelling out against a half finished download. --- .../downloads/downloads_ui_tests.rs | 127 +++++++++++++++ src/ui/readarr_ui/downloads/mod.rs | 146 ++++++++++++++++++ ...nd_item_selected_DeleteDownloadPrompt.snap | 38 +++++ ...nloads_second_item_selected_Downloads.snap | 8 + ...ts__downloads_ui_DeleteDownloadPrompt.snap | 38 +++++ ...napshot_tests__downloads_ui_Downloads.snap | 7 + ...s__downloads_ui_UpdateDownloadsPrompt.snap | 38 +++++ ...hot_tests__downloads_ui_renders_empty.snap | 5 + ...t_tests__downloads_ui_renders_loading.snap | 8 + src/ui/readarr_ui/mod.rs | 3 + ...napshot_tests__readarr_tabs_Downloads.snap | 6 +- 11 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 src/ui/readarr_ui/downloads/downloads_ui_tests.rs create mode 100644 src/ui/readarr_ui/downloads/mod.rs create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap create mode 100644 src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap diff --git a/src/ui/readarr_ui/downloads/downloads_ui_tests.rs b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs new file mode 100644 index 00000000..565b470a --- /dev/null +++ b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs @@ -0,0 +1,127 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::downloads::DownloadsUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_downloads_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_readarr_block) { + assert!(DownloadsUi::accepts(active_readarr_block.into())); + } else { + assert!(!DownloadsUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use rstest::rstest; + use serde_json::Number; + + use crate::models::readarr_models::{DownloadRecord, DownloadStatus}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_downloads_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_downloads_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_downloads_ui_renders( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("downloads_ui_{active_readarr_block}"), output); + } + + #[rstest] + fn test_downloads_ui_renders_second_item_selected( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.downloads.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("downloads_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn downloads_vec() -> Vec { + vec![ + DownloadRecord { + id: 1, + title: "First Download Title".to_owned(), + status: DownloadStatus::Downloading, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some("/nfs/books/First Author".into()), + indexer: "DrunkenSlug".to_owned(), + download_client: Some("sabnzbd".to_owned()), + }, + DownloadRecord { + id: 2, + title: "Second Download Title".to_owned(), + status: DownloadStatus::Paused, + book_id: Some(Number::from(2)), + author_id: Some(Number::from(2)), + size: 1073741824f64, + sizeleft: 268435456f64, + output_path: Some("/nfs/books/Second Author".into()), + indexer: "Nyaa".to_owned(), + download_client: Some("transmission".to_owned()), + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/downloads/mod.rs b/src/ui/readarr_ui/downloads/mod.rs new file mode 100644 index 00000000..ac1a7c6e --- /dev/null +++ b/src/ui/readarr_ui/downloads/mod.rs @@ -0,0 +1,146 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::readarr_models::DownloadRecord; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::{HorizontallyScrollableText, Route}; +use crate::ui::DrawUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::utils::convert_f64_to_gb; + +#[cfg(test)] +#[path = "downloads_ui_tests.rs"] +mod downloads_ui_tests; + +pub(super) struct DownloadsUi; + +impl DrawUi for DownloadsUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return DOWNLOADS_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_downloads(f, app, area); + + match active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt => { + let prompt = format!( + "Do you really want to delete this download: \n{}?", + app.data.readarr_data.downloads.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Cancel Download") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update Downloads") + .prompt("Do you want to update your downloads?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.readarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.readarr_data.downloads.current_selection().clone() + }; + + let downloads_row_mapping = |download_record: &DownloadRecord| { + let DownloadRecord { + title, + size, + sizeleft, + download_client, + indexer, + output_path, + .. + } = download_record; + + if output_path.is_some() { + output_path.as_ref().unwrap().scroll_left_or_reset( + get_width_from_percentage(area, 18), + current_selection == *download_record, + app.should_text_scroll, + ); + } + + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let file_size: f64 = convert_f64_to_gb(*size); + + Row::new(vec![ + Cell::from(title.to_owned()), + Cell::from(format!("{:.0}%", percent * 100.0)), + Cell::from(format!("{file_size:.2} GB")), + Cell::from( + output_path + .as_ref() + .unwrap_or(&HorizontallyScrollableText::default()) + .to_string(), + ), + Cell::from(indexer.to_owned()), + Cell::from( + download_client + .as_ref() + .unwrap_or(&String::new()) + .to_owned(), + ), + ]) + .primary() + }; + let downloads_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.downloads), + downloads_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers([ + "Title", + "Percent Complete", + "Size", + "Output Path", + "Indexer", + "Download Client", + ]) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(18), + Constraint::Percentage(17), + Constraint::Percentage(13), + ]); + + f.render_widget(downloads_table, area); +} diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..f0c11f76 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Second Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap new file mode 100644 index 00000000..4c7f4950 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..697b474e --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Test Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap new file mode 100644 index 00000000..fb117252 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap new file mode 100644 index 00000000..7c0ecc17 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭─────────────────── Update Downloads ────────────────────╮ + │ Do you want to update your downloads? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap new file mode 100644 index 00000000..982f0817 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap new file mode 100644 index 00000000..c74ac942 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs index e9922aea..5477ab33 100644 --- a/src/ui/readarr_ui/mod.rs +++ b/src/ui/readarr_ui/mod.rs @@ -22,6 +22,7 @@ use super::{ widgets::loading_block::LoadingBlock, }; use crate::ui::readarr_ui::blocklist::BlocklistUi; +use crate::ui::readarr_ui::downloads::DownloadsUi; use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; use crate::{ app::App, @@ -36,6 +37,7 @@ use crate::{ }; mod blocklist; +mod downloads; mod library; mod readarr_ui_utils; @@ -56,6 +58,7 @@ impl DrawUi for ReadarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ => (), } diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap index d3f01721..86d9b348 100644 --- a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap @@ -4,9 +4,9 @@ expression: output --- ╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ -│ │ -│ │ -│ │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Title Percent Complete Size Output Path Indexer Download Client │ +│=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission │ │ │ │ │ │ │