From bd1a4f093916c7926f98cab30264178da08ebc4d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 5 Dec 2024 19:07:45 -0700 Subject: [PATCH] feat(ui): Sonarr Series details UI is now available --- src/app/sonarr/sonarr_context_clues.rs | 3 +- src/app/sonarr/sonarr_context_clues_tests.rs | 5 + src/models/servarr_data/sonarr/sonarr_data.rs | 15 + .../servarr_data/sonarr/sonarr_data_tests.rs | 18 +- src/ui/sonarr_ui/library/mod.rs | 98 ++--- src/ui/sonarr_ui/library/series_details_ui.rs | 395 ++++++++++++++++++ .../library/series_details_ui_tests.rs | 21 + src/ui/sonarr_ui/mod.rs | 1 + src/ui/sonarr_ui/sonarr_ui_utils.rs | 183 ++++++++ src/ui/sonarr_ui/sonarr_ui_utils_tests.rs | 240 +++++++++++ 10 files changed, 929 insertions(+), 50 deletions(-) create mode 100644 src/ui/sonarr_ui/library/series_details_ui.rs create mode 100644 src/ui/sonarr_ui/library/series_details_ui_tests.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_utils.rs create mode 100644 src/ui/sonarr_ui/sonarr_ui_utils_tests.rs diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 2db8ff7..37878bf 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -40,7 +40,8 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static HISTORY_CONTEXT_CLUES: [ContextClue; 5] = [ +pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 0ad23ef..c5a9fe3 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -92,6 +92,11 @@ mod tests { let (key_binding, description) = history_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); + assert_str_eq!(*description, "details"); + + let (key_binding, description) = history_context_clues_iter.next().unwrap(); + assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index acec1c2..70fd9ca 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -278,6 +278,7 @@ pub enum ActiveSonarrBlock { Series, SeriesDetails, SeriesHistory, + SeriesHistoryDetails, SeriesHistorySortPrompt, SeriesSortPrompt, System, @@ -303,6 +304,20 @@ pub static LIBRARY_BLOCKS: [ActiveSonarrBlock; 7] = [ ActiveSonarrBlock::UpdateAllSeriesPrompt, ]; +pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 11] = [ + ActiveSonarrBlock::SeriesDetails, + ActiveSonarrBlock::SeriesHistory, + ActiveSonarrBlock::SearchSeason, + ActiveSonarrBlock::SearchSeasonError, + ActiveSonarrBlock::UpdateAndScanSeriesPrompt, + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::SearchSeriesHistoryError, + ActiveSonarrBlock::FilterSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistoryError, + ActiveSonarrBlock::SeriesHistorySortPrompt, + ActiveSonarrBlock::SeriesHistoryDetails, +]; + pub static ADD_SERIES_BLOCKS: [ActiveSonarrBlock; 13] = [ ActiveSonarrBlock::AddSeriesAlreadyInLibrary, ActiveSonarrBlock::AddSeriesConfirmPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 0e73f33..28f2d34 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -207,7 +207,7 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, - SYSTEM_DETAILS_BLOCKS, + SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -567,5 +567,21 @@ mod tests { assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemTaskStartConfirmPrompt)); assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SystemUpdates)); } + + #[test] + fn test_series_details_blocks_contents() { + assert_eq!(SERIES_DETAILS_BLOCKS.len(), 11); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesDetails)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeason)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeasonError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::UpdateAndScanSeriesPrompt)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SearchSeriesHistoryError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesHistory)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::FilterSeriesHistoryError)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistorySortPrompt)); + assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistoryDetails)); + } } } diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 4ab9e25..930ecca 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -6,10 +6,10 @@ use ratatui::{ widgets::{Cell, Row}, Frame, }; +use series_details_ui::SeriesDetailsUi; use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, - message::Message, popup::{Popup, Size}, }; use crate::{ @@ -20,7 +20,6 @@ use crate::{ EnumDisplayStyle, Route, }, ui::{ - draw_input_box_popup, draw_popup_over, styles::ManagarrStyle, utils::{get_width_from_percentage, layout_block_top_border}, widgets::managarr_table::ManagarrTable, @@ -31,6 +30,7 @@ use crate::{ mod add_series_ui; mod delete_series_ui; mod edit_series_ui; +mod series_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] @@ -44,6 +44,7 @@ impl DrawUi for LibraryUi { return AddSeriesUi::accepts(route) || DeleteSeriesUi::accepts(route) || EditSeriesUi::accepts(route) + || SeriesDetailsUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_sonarr_block); } @@ -54,36 +55,41 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); let mut series_ui_matchers = |active_sonarr_block: ActiveSonarrBlock| match active_sonarr_block { - ActiveSonarrBlock::Series | ActiveSonarrBlock::SeriesSortPrompt => draw_library(f, app, area), - ActiveSonarrBlock::SearchSeries => draw_popup_over( - f, - app, - area, - draw_library, - draw_library_search_box, - Size::InputBox, - ), - ActiveSonarrBlock::SearchSeriesError => { - let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); + ActiveSonarrBlock::Series + | ActiveSonarrBlock::SeriesSortPrompt + | ActiveSonarrBlock::SearchSeries + | ActiveSonarrBlock::SearchSeriesError + | ActiveSonarrBlock::FilterSeries + | ActiveSonarrBlock::FilterSeriesError => draw_library(f, app, area), + // ActiveSonarrBlock::SearchSeries => draw_popup_over( + // f, + // app, + // area, + // draw_library, + // draw_library_search_box, + // Size::InputBox, + // ), + // ActiveSonarrBlock::SearchSeriesError => { + // let popup = Popup::new(Message::new("Series not found!")).size(Size::Message); - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } - ActiveSonarrBlock::FilterSeries => draw_popup_over( - f, - app, - area, - draw_library, - draw_filter_series_box, - Size::InputBox, - ), - ActiveSonarrBlock::FilterSeriesError => { - let popup = Popup::new(Message::new("No series found matching the given filter!")) - .size(Size::Message); + // draw_library(f, app, area); + // f.render_widget(popup, f.area()); + // } + // ActiveSonarrBlock::FilterSeries => draw_popup_over( + // f, + // app, + // area, + // draw_library, + // draw_filter_series_box, + // Size::InputBox, + // ), + // ActiveSonarrBlock::FilterSeriesError => { + // let popup = Popup::new(Message::new("No series found matching the given filter!")) + // .size(Size::Message); - draw_library(f, app, area); - f.render_widget(popup, f.area()); - } + // draw_library(f, app, area); + // f.render_widget(popup, f.area()); + // } ActiveSonarrBlock::UpdateAllSeriesPrompt => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Series") @@ -103,6 +109,7 @@ impl DrawUi for LibraryUi { _ if AddSeriesUi::accepts(route) => AddSeriesUi::draw(f, app, area), _ if DeleteSeriesUi::accepts(route) => DeleteSeriesUi::draw(f, app, area), _ if EditSeriesUi::accepts(route) => EditSeriesUi::draw(f, app, area), + _ if SeriesDetailsUi::accepts(route) => SeriesDetailsUi::draw(f, app, area), Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } @@ -182,6 +189,10 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .loading(app.is_loading) .footer(help_footer) .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesSortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeries) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeries) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeriesError) + .filter_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::FilterSeriesError) .headers([ "Title", "Year", @@ -207,6 +218,15 @@ pub(super) fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Constraint::Percentage(12), ]); + if [ + ActiveSonarrBlock::SearchSeries, + ActiveSonarrBlock::FilterSeries, + ] + .contains(&active_sonarr_block) + { + series_table.show_cursor(f, area); + } + f.render_widget(series_table, area); } } @@ -235,21 +255,3 @@ fn decorate_series_row_with_style<'a>(series: &Series, row: Row<'a>) -> Row<'a> _ => row.missing(), } } - -fn draw_library_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Search", - app.data.sonarr_data.series.search.as_ref().unwrap(), - ); -} - -fn draw_filter_series_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - draw_input_box_popup( - f, - area, - "Filter", - app.data.sonarr_data.series.filter.as_ref().unwrap(), - ) -} diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs new file mode 100644 index 0000000..d673881 --- /dev/null +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -0,0 +1,395 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + Season, SeasonStatistics, SonarrHistoryEventType, SonarrHistoryItem, +}; +use crate::models::{EnumDisplayStyle, Route}; +use crate::ui::sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, +}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, get_width_from_percentage, layout_block_top_border, title_block, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_popup_over, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; + +use super::draw_library; + +#[cfg(test)] +#[path = "series_details_ui_tests.rs"] +mod series_details_ui_tests; + +pub(super) struct SeriesDetailsUi; + +impl DrawUi for SeriesDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return SERIES_DETAILS_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() { + let draw_series_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + f.render_widget( + title_block(&app.data.sonarr_data.series.current_selection().title.text), + popup_area, + ); + let [description_area, detail_area] = + Layout::vertical([Constraint::Percentage(37), Constraint::Fill(0)]) + .margin(1) + .areas(popup_area); + draw_series_description(f, app, description_area); + let content_area = draw_tabs( + f, + detail_area, + "Series Details", + &app.data.sonarr_data.series_info_tabs, + ); + draw_series_details(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::UpdateAndScanSeriesPrompt => { + let prompt = format!( + "Do you want to trigger an update and disk scan for the series: {}?", + app.data.sonarr_data.series.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update and Scan") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::SeriesHistoryDetails => { + draw_popup_over( + f, + app, + popup_area, + draw_series_history_table, + draw_history_item_details_popup, + Size::Small, + ); + } + _ => (), + }; + }; + + draw_popup_over( + f, + app, + area, + draw_library, + draw_series_details_popup, + Size::XXLarge, + ); + } + } +} + +pub fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = app.data.sonarr_data.series.current_selection(); + let monitored = if current_selection.monitored { + "Yes" + } else { + "No" + }; + let quality_profile = app + .data + .sonarr_data + .quality_profile_map + .get_by_left(¤t_selection.quality_profile_id) + .unwrap() + .to_owned(); + let language_profile = app + .data + .sonarr_data + .language_profiles_map + .get_by_left(¤t_selection.language_profile_id) + .unwrap() + .to_owned(); + let mut series_description = vec![ + Line::from(vec![ + "Title: ".primary().bold(), + current_selection.title.text.clone().primary().bold(), + ]), + Line::from(vec![ + "Overview: ".primary().bold(), + current_selection + .overview + .clone() + .unwrap_or_default() + .default(), + ]), + Line::from(vec![ + "Network: ".primary().bold(), + current_selection + .network + .clone() + .unwrap_or_default() + .default(), + ]), + Line::from(vec![ + "Status: ".primary().bold(), + current_selection.status.to_display_str().default(), + ]), + Line::from(vec![ + "Genres: ".primary().bold(), + current_selection.genres.join(", ").default(), + ]), + Line::from(vec![ + "Rating: ".primary().bold(), + format!("{}%", (current_selection.ratings.value * 10.0) as i32).default(), + ]), + Line::from(vec![ + "Year: ".primary().bold(), + current_selection.year.to_string().default(), + ]), + Line::from(vec![ + "Runtime: ".primary().bold(), + format!("{} minutes", current_selection.runtime).default(), + ]), + Line::from(vec![ + "Path: ".primary().bold(), + current_selection.path.clone().default(), + ]), + Line::from(vec![ + "Quality Profile: ".primary().bold(), + quality_profile.default(), + ]), + Line::from(vec![ + "Language Profile: ".primary().bold(), + language_profile.default(), + ]), + Line::from(vec!["Monitored: ".primary().bold(), monitored.default()]), + ]; + if let Some(stats) = current_selection.statistics.as_ref() { + let size = convert_to_gb(stats.size_on_disk); + series_description.extend(vec![Line::from(vec![ + "Size on Disk: ".primary().bold(), + format!("{size:.2} GB").default(), + ])]); + } + + let description_paragraph = Paragraph::new(series_description) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + f.render_widget(description_paragraph, area); +} + +pub fn draw_series_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = + app.data.sonarr_data.series_info_tabs.get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::SeriesDetails => draw_seasons_table(f, app, area), + ActiveSonarrBlock::SeriesHistory => draw_series_history_table(f, app, area), + _ => (), + } + } +} + +fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let content = Some(&mut app.data.sonarr_data.seasons); + let help_footer = app + .data + .sonarr_data + .series_info_tabs + .get_active_tab_contextual_help(); + let season_row_mapping = |season: &Season| { + let Season { + season_number, + monitored, + statistics, + } = season; + let SeasonStatistics { + episode_count, + total_episode_count, + size_on_disk, + .. + } = statistics; + let season_monitored = if season.monitored { "🏷" } else { "" }; + let size = convert_to_gb(*size_on_disk); + + let row = Row::new(vec![ + Cell::from(season_monitored.to_owned()), + Cell::from(format!("Season {}", season_number)), + Cell::from(format!("{}/{}", episode_count, total_episode_count)), + Cell::from(format!("{size:.2} GB")), + ]); + if episode_count == total_episode_count { + row.downloaded() + } else if !monitored { + row.unmonitored() + } else { + row.missing() + } + }; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let season_table = ManagarrTable::new(content, season_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .searching(is_searching) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .headers(["Monitored", "Season", "Episode Count", "Size on Disk"]) + .constraints([ + Constraint::Percentage(6), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]); + + if is_searching { + season_table.show_cursor(f, area); + } + + f.render_widget(season_table, area); + } +} + +fn draw_series_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.series_history.as_ref() { + Some(series_history) if !app.is_loading => { + let current_selection = if series_history.is_empty() { + SonarrHistoryItem::default() + } else { + series_history.current_selection().clone() + }; + let series_history_table_footer = app + .data + .sonarr_data + .series_info_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &SonarrHistoryItem| { + let SonarrHistoryItem { + source_title, + language, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.tick_count % app.ticks_until_scroll == 0, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(language.name.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut series_history_table = app.data.sonarr_data.series_history.as_mut().unwrap(); + let history_table = + ManagarrTable::new(Some(&mut series_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(series_history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeriesHistorySortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeriesHistory) + .search_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::SearchSeriesHistoryError, + ) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeriesHistory) + .filter_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::FilterSeriesHistoryError, + ) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + if [ + ActiveSonarrBlock::SearchSeriesHistory, + ActiveSonarrBlock::FilterSeriesHistory, + ] + .contains(&active_sonarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.radarr_data.movie_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = + if let Some(series_history_items) = app.data.sonarr_data.series_history.as_ref() { + if series_history_items.is_empty() { + SonarrHistoryItem::default() + } else { + series_history_items.current_selection().clone() + } + } else { + SonarrHistoryItem::default() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Grabbed => create_grabbed_history_event_details(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_history_event_details(current_selection) + } + SonarrHistoryEventType::DownloadFailed => { + create_download_failed_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_history_event_details(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_history_event_details(current_selection) + } + _ => create_no_data_history_event_details(current_selection), + }; + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(Style::new().secondary()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); +} diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs new file mode 100644 index 0000000..7dd2f8d --- /dev/null +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; + use crate::ui::DrawUi; + + #[test] + fn test_series_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(SeriesDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SeriesDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index c871d17..845daea 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -44,6 +44,7 @@ mod history; mod indexers; mod library; mod root_folders; +mod sonarr_ui_utils; mod system; #[cfg(test)] diff --git a/src/ui/sonarr_ui/sonarr_ui_utils.rs b/src/ui/sonarr_ui/sonarr_ui_utils.rs new file mode 100644 index 0000000..8fff51a --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_utils.rs @@ -0,0 +1,183 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; + +use crate::models::sonarr_models::{SonarrHistoryData, SonarrHistoryItem}; +use crate::ui::styles::ManagarrStyle; + +#[cfg(test)] +#[path = "sonarr_ui_utils_tests.rs"] +mod sonarr_ui_utils_tests; + +pub(super) fn create_grabbed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + indexer, + release_group, + series_match_type, + nzb_info_url, + download_client_name, + age, + published_date, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Release Group: ".bold().secondary(), + release_group.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Series Match Type: ".bold().secondary(), + series_match_type.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "NZB Info URL: ".bold().secondary(), + nzb_info_url.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Download Client Name: ".bold().secondary(), + download_client_name.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Age: ".bold().secondary(), + format!("{} days", age.unwrap_or("0".to_owned())).secondary(), + ]), + Line::from(vec![ + "Published Date: ".bold().secondary(), + published_date.unwrap_or_default().to_string().secondary(), + ]), + ] +} + +pub(super) fn create_download_folder_imported_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + dropped_path, + imported_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Dropped Path: ".bold().secondary(), + dropped_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Imported Path: ".bold().secondary(), + imported_path.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_download_failed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { message, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_episode_file_deleted_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { reason, .. } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Reason: ".bold().secondary(), + reason.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_episode_file_renamed_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { + source_title, data, .. + } = history_item; + let SonarrHistoryData { + source_path, + source_relative_path, + path, + relative_path, + .. + } = data; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Source Path: ".bold().secondary(), + source_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Source Relative Path: ".bold().secondary(), + source_relative_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Path: ".bold().secondary(), + path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Relative Path: ".bold().secondary(), + relative_path.unwrap_or_default().secondary(), + ]), + ] +} + +pub(super) fn create_no_data_history_event_details( + history_item: SonarrHistoryItem, +) -> Vec> { + let SonarrHistoryItem { source_title, .. } = history_item; + + vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![String::new().secondary()]), + Line::from(vec!["No additional data available".bold().secondary()]), + ] +} diff --git a/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs b/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs new file mode 100644 index 0000000..a0255c2 --- /dev/null +++ b/src/ui/sonarr_ui/sonarr_ui_utils_tests.rs @@ -0,0 +1,240 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use ratatui::{style::Stylize, text::Line}; + + use crate::{ + models::sonarr_models::{SonarrHistoryData, SonarrHistoryItem}, + ui::{ + sonarr_ui::sonarr_ui_utils::{ + create_download_failed_history_event_details, + create_download_folder_imported_history_event_details, + create_episode_file_deleted_history_event_details, + create_episode_file_renamed_history_event_details, create_grabbed_history_event_details, + create_no_data_history_event_details, + }, + styles::ManagarrStyle, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_create_grabbed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + indexer, + release_group, + series_match_type, + nzb_info_url, + download_client_name, + age, + published_date, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Release Group: ".bold().secondary(), + release_group.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Series Match Type: ".bold().secondary(), + series_match_type.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "NZB Info URL: ".bold().secondary(), + nzb_info_url.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Download Client Name: ".bold().secondary(), + download_client_name.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Age: ".bold().secondary(), + format!("{} days", age.unwrap_or("0".to_owned())).secondary(), + ]), + Line::from(vec![ + "Published Date: ".bold().secondary(), + published_date.unwrap_or_default().to_string().secondary(), + ]), + ]; + + let history_details_vec = create_grabbed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_download_folder_imported_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + dropped_path, + imported_path, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Dropped Path: ".bold().secondary(), + dropped_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Imported Path: ".bold().secondary(), + imported_path.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_download_folder_imported_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_download_failed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { message, .. } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_download_failed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_episode_file_deleted_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { reason, .. } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Reason: ".bold().secondary(), + reason.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_episode_file_deleted_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_episode_file_renamed_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { + source_title, data, .. + } = history_item.clone(); + let SonarrHistoryData { + source_path, + source_relative_path, + path, + relative_path, + .. + } = data; + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![ + "Source Path: ".bold().secondary(), + source_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Source Relative Path: ".bold().secondary(), + source_relative_path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Path: ".bold().secondary(), + path.unwrap_or_default().secondary(), + ]), + Line::from(vec![ + "Destination Relative Path: ".bold().secondary(), + relative_path.unwrap_or_default().secondary(), + ]), + ]; + + let history_details_vec = create_episode_file_renamed_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + #[test] + fn test_create_no_data_history_event_details() { + let history_item = sonarr_history_item(); + let SonarrHistoryItem { source_title, .. } = history_item.clone(); + let expected_vec = vec![ + Line::from(vec![ + "Source Title: ".bold().secondary(), + source_title.text.secondary(), + ]), + Line::from(vec![String::new().secondary()]), + Line::from(vec!["No additional data available".bold().secondary()]), + ]; + + let history_details_vec = create_no_data_history_event_details(history_item); + + assert_eq!(expected_vec, history_details_vec); + } + + fn sonarr_history_item() -> SonarrHistoryItem { + SonarrHistoryItem { + source_title: "test.source.title".into(), + data: sonarr_history_data(), + ..SonarrHistoryItem::default() + } + } + + fn sonarr_history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: Some("/dropped/test".into()), + imported_path: Some("/imported/test".into()), + indexer: Some("Test Indexer".into()), + release_group: Some("test release group".into()), + series_match_type: Some("test match type".into()), + nzb_info_url: Some("test url".into()), + download_client_name: Some("test download client".into()), + age: Some("1".into()), + published_date: Some(Utc::now()), + message: Some("test message".into()), + reason: Some("test reason".into()), + source_path: Some("/source/path".into()), + source_relative_path: Some("/relative/source/path".into()), + path: Some("/path".into()), + relative_path: Some("/relative/path".into()), + } + } +}