From f0d8555a8ad2c5cb2d31fb07c9962c7513d2979e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 15:57:48 -0700 Subject: [PATCH] feat(ui): Downloads tab support --- src/handlers/sonarr_handlers/mod.rs | 4 + .../sonarr_handlers/sonarr_handler_tests.rs | 60 ++++---- .../sonarr_ui/downloads/downloads_ui_tests.rs | 19 +++ src/ui/sonarr_ui/downloads/mod.rs | 142 ++++++++++++++++++ src/ui/sonarr_ui/mod.rs | 3 + src/utils.rs | 4 + src/utils_tests.rs | 8 +- 7 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 src/ui/sonarr_ui/downloads/downloads_ui_tests.rs create mode 100644 src/ui/sonarr_ui/downloads/mod.rs diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 8faa4ce..c3a26f5 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -1,3 +1,4 @@ +use downloads::DownloadsHandler; use library::LibraryHandler; use crate::{ @@ -32,6 +33,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b _ if LibraryHandler::accepts(self.active_sonarr_block) => { LibraryHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle(); } + _ if DownloadsHandler::accepts(self.active_sonarr_block) => { + DownloadsHandler::with(self.key, self.app, self.active_sonarr_block, self.context).handle() + } _ => self.handle_key_event(), } } diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 4fcc1bd..e9c21a4 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -48,36 +48,28 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - // ActiveSonarrBlock::AddSeriesAlreadyInLibrary, - // ActiveSonarrBlock::AddSeriesConfirmPrompt, - // ActiveSonarrBlock::AddSeriesEmptySearchResults, - // ActiveSonarrBlock::AddSeriesPrompt, - // ActiveSonarrBlock::AddSeriesSearchInput, - // ActiveSonarrBlock::AddSeriesSearchResults, - // ActiveSonarrBlock::AddSeriesSelectLanguageProfile, - // ActiveSonarrBlock::AddSeriesSelectMonitor, - // ActiveSonarrBlock::AddSeriesSelectQualityProfile, - // ActiveSonarrBlock::AddSeriesSelectRootFolder, - // ActiveSonarrBlock::AddSeriesSelectSeriesType, - // ActiveSonarrBlock::AddSeriesTagsInput, - // ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder, + ActiveSonarrBlock::AddSeriesAlreadyInLibrary, + ActiveSonarrBlock::AddSeriesEmptySearchResults, + ActiveSonarrBlock::AddSeriesPrompt, + ActiveSonarrBlock::AddSeriesSearchInput, + ActiveSonarrBlock::AddSeriesSearchResults, + ActiveSonarrBlock::AddSeriesSelectLanguageProfile, + ActiveSonarrBlock::AddSeriesSelectMonitor, + ActiveSonarrBlock::AddSeriesSelectQualityProfile, + ActiveSonarrBlock::AddSeriesSelectRootFolder, + ActiveSonarrBlock::AddSeriesSelectSeriesType, + ActiveSonarrBlock::AddSeriesTagsInput, // ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, // ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, // ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, // ActiveSonarrBlock::DeleteEpisodeFilePrompt, - // ActiveSonarrBlock::DeleteSeriesConfirmPrompt, - // ActiveSonarrBlock::DeleteSeriesPrompt, - // ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, - // ActiveSonarrBlock::DeleteSeriesToggleDeleteFile, - // ActiveSonarrBlock::EditSeriesPrompt, - // ActiveSonarrBlock::EditSeriesConfirmPrompt, - // ActiveSonarrBlock::EditSeriesPathInput, - // ActiveSonarrBlock::EditSeriesSelectSeriesType, - // ActiveSonarrBlock::EditSeriesSelectQualityProfile, - // ActiveSonarrBlock::EditSeriesSelectLanguageProfile, - // ActiveSonarrBlock::EditSeriesTagsInput, - // ActiveSonarrBlock::EditSeriesToggleMonitored, - // ActiveSonarrBlock::EditSeriesToggleSeasonFolder, + ActiveSonarrBlock::DeleteSeriesPrompt, + ActiveSonarrBlock::EditSeriesPrompt, + ActiveSonarrBlock::EditSeriesPathInput, + ActiveSonarrBlock::EditSeriesSelectSeriesType, + ActiveSonarrBlock::EditSeriesSelectQualityProfile, + ActiveSonarrBlock::EditSeriesSelectLanguageProfile, + ActiveSonarrBlock::EditSeriesTagsInput, // ActiveSonarrBlock::EpisodeDetails, // ActiveSonarrBlock::EpisodeFile, // ActiveSonarrBlock::EpisodeHistory, @@ -119,4 +111,20 @@ mod tests { active_sonarr_block ); } + + #[rstest] + fn test_delegates_downloads_blocks_to_downloads_handler( + #[values( + ActiveSonarrBlock::Downloads, + ActiveSonarrBlock::DeleteDownloadPrompt, + ActiveSonarrBlock::UpdateDownloadsPrompt + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + test_handler_delegation!( + SonarrHandler, + ActiveSonarrBlock::Downloads, + active_sonarr_block + ); + } } diff --git a/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs b/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs new file mode 100644 index 0000000..2d040ae --- /dev/null +++ b/src/ui/sonarr_ui/downloads/downloads_ui_tests.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; + use crate::ui::sonarr_ui::downloads::DownloadsUi; + use crate::ui::DrawUi; + + #[test] + fn test_downloads_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_sonarr_block) { + assert!(DownloadsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!DownloadsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs new file mode 100644 index 0000000..4ecdd1f --- /dev/null +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -0,0 +1,142 @@ +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::sonarr_models::DownloadRecord; +use crate::models::{HorizontallyScrollableText, Route}; +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::ui::DrawUi; +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::Sonarr(active_sonarr_block, _) = route { + return DOWNLOADS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + match active_sonarr_block { + ActiveSonarrBlock::Downloads => draw_downloads(f, app, area), + ActiveSonarrBlock::DeleteDownloadPrompt => { + let prompt = format!( + "Do you really want to delete this download: \n{}?", + app.data.sonarr_data.downloads.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Cancel Download") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_downloads(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + ActiveSonarrBlock::UpdateDownloadsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update Downloads") + .prompt("Do you want to update your downloads?") + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + draw_downloads(f, app, area); + f.render_widget(Popup::new(confirmation_prompt).size(Size::Prompt), f.area()); + } + _ => (), + } + } + } +} + +fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.sonarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.sonarr_data.downloads.current_selection().clone() + }; + let downloads_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + 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.tick_count % app.ticks_until_scroll == 0, + ); + } + + 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.to_owned()), + ]) + .primary() + }; + let downloads_table = ManagarrTable::new( + Some(&mut app.data.sonarr_data.downloads), + downloads_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(downloads_table_footer) + .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/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 18a8fce..b10f4aa 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -1,6 +1,7 @@ use std::{cmp, iter}; use chrono::{Duration, Utc}; +use downloads::DownloadsUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -32,6 +33,7 @@ use super::{ DrawUi, }; +mod downloads; mod library; #[cfg(test)] @@ -51,6 +53,7 @@ impl DrawUi for SonarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), _ => (), } } diff --git a/src/utils.rs b/src/utils.rs index b5e98db..741e6e4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -67,6 +67,10 @@ pub fn convert_to_gb(bytes: i64) -> f64 { bytes as f64 / 1024f64.powi(3) } +pub fn convert_f64_to_gb(bytes: f64) -> f64 { + bytes / 1024f64.powi(3) +} + pub fn convert_runtime(runtime: i64) -> (i64, i64) { let hours = runtime / 60; let minutes = runtime % 60; diff --git a/src/utils_tests.rs b/src/utils_tests.rs index 6c74458..7e9a533 100644 --- a/src/utils_tests.rs +++ b/src/utils_tests.rs @@ -2,7 +2,7 @@ mod tests { use pretty_assertions::assert_eq; - use crate::utils::{convert_runtime, convert_to_gb}; + use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb}; #[test] fn test_convert_to_gb() { @@ -10,6 +10,12 @@ mod tests { assert_eq!(convert_to_gb(2662879723), 2.4799999995157123); } + #[test] + fn test_convert_f64_to_gb() { + assert_eq!(convert_f64_to_gb(2147483648f64), 2f64); + assert_eq!(convert_f64_to_gb(2662879723f64), 2.4799999995157123); + } + #[test] fn test_convert_runtime() { let (hours, minutes) = convert_runtime(154);