From 4eb974567f0a1afa7d24e80fd9f23fa511c8bd52 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 2 Dec 2024 18:47:50 -0700 Subject: [PATCH] feat(ui): History tab support --- src/app/sonarr/mod.rs | 2 +- src/app/sonarr/sonarr_tests.rs | 2 +- .../history/history_handler_tests.rs | 11 +- src/handlers/sonarr_handlers/history/mod.rs | 3 +- src/models/sonarr_models.rs | 6 +- src/network/sonarr_network_tests.rs | 4 +- src/ui/sonarr_ui/blocklist/mod.rs | 2 +- src/ui/sonarr_ui/history/history_ui_tests.rs | 18 + src/ui/sonarr_ui/history/mod.rs | 347 ++++++++++++++++++ src/ui/sonarr_ui/mod.rs | 3 + 10 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 src/ui/sonarr_ui/history/history_ui_tests.rs create mode 100644 src/ui/sonarr_ui/history/mod.rs diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 563d7b5..9b6a30b 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -69,7 +69,7 @@ impl<'a> App<'a> { } ActiveSonarrBlock::History => { self - .dispatch_network_event(SonarrEvent::GetHistory(Some(10000)).into()) + .dispatch_network_event(SonarrEvent::GetHistory(None).into()) .await; } ActiveSonarrBlock::RootFolders => { diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 123212b..a02c217 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -179,7 +179,7 @@ mod tests { assert!(app.is_loading); assert_eq!( sync_network_rx.recv().await.unwrap(), - SonarrEvent::GetHistory(Some(10000)).into() + SonarrEvent::GetHistory(None).into() ); assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index e981ec6..2110070 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -14,7 +14,7 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::sonarr_models::{SonarrHistoryEventType, SonarrHistoryItem}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; @@ -1197,8 +1197,9 @@ mod tests { fn test_history_sorting_options_event_type() { let expected_cmp_fn: fn(&SonarrHistoryItem, &SonarrHistoryItem) -> Ordering = |a, b| { a.event_type + .to_string() .to_lowercase() - .cmp(&b.event_type.to_lowercase()) + .cmp(&b.event_type.to_string().to_lowercase()) }; let mut expected_history_vec = history_vec(); expected_history_vec.sort_by(expected_cmp_fn); @@ -1334,7 +1335,7 @@ mod tests { SonarrHistoryItem { id: 3, source_title: "test 1".into(), - event_type: "grabbed".to_owned(), + event_type: SonarrHistoryEventType::Grabbed, language: Language { id: 1, name: "telgu".to_owned(), @@ -1350,7 +1351,7 @@ mod tests { SonarrHistoryItem { id: 2, source_title: "test 2".into(), - event_type: "downloadFolderImported".to_owned(), + event_type: SonarrHistoryEventType::DownloadFolderImported, language: Language { id: 3, name: "chinese".to_owned(), @@ -1366,7 +1367,7 @@ mod tests { SonarrHistoryItem { id: 1, source_title: "test 3".into(), - event_type: "episodeFileDeleted".to_owned(), + event_type: SonarrHistoryEventType::EpisodeFileDeleted, language: Language { id: 1, name: "english".to_owned(), diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 7b7069c..4d0597b 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -323,8 +323,9 @@ fn history_sorting_options() -> Vec> { name: "Event Type", cmp_fn: Some(|a, b| { a.event_type + .to_string() .to_lowercase() - .cmp(&b.event_type.to_lowercase()) + .cmp(&b.event_type.to_string().to_lowercase()) }), }, SortOption { diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 1e8bcb9..f3c9e9b 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -465,6 +465,10 @@ pub struct SonarrHistoryData { pub published_date: Option>, pub message: Option, pub reason: Option, + pub source_path: Option, + pub source_relative_path: Option, + pub path: Option, + pub relative_path: Option, } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] @@ -523,7 +527,7 @@ pub struct SonarrHistoryItem { pub quality: QualityWrapper, pub language: Language, pub date: DateTime, - pub event_type: String, + pub event_type: SonarrHistoryEventType, pub data: SonarrHistoryData, } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 0493a0f..197f0c8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,7 +17,7 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - EditSeriesParams, IndexerSettings, SeriesMonitor, + EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, }; use crate::app::{App, ServarrConfig}; @@ -6780,7 +6780,7 @@ mod test { quality: quality_wrapper(), language: language(), date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), - event_type: "grabbed".into(), + event_type: SonarrHistoryEventType::Grabbed, data: history_data(), } } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 08448c8..74a2d52 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -116,7 +116,7 @@ fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .headers([ "Series Title", "Source Title", - "Languages", + "Language", "Quality", "Date", ]) diff --git a/src/ui/sonarr_ui/history/history_ui_tests.rs b/src/ui/sonarr_ui/history/history_ui_tests.rs new file mode 100644 index 0000000..bae662e --- /dev/null +++ b/src/ui/sonarr_ui/history/history_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; + use crate::ui::sonarr_ui::history::HistoryUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_history_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if HISTORY_BLOCKS.contains(&active_sonarr_block) { + assert!(HistoryUi::accepts(active_sonarr_block.into())); + } else { + assert!(!HistoryUi::accepts(active_sonarr_block.into())); + } + }); + } +} diff --git a/src/ui/sonarr_ui/history/mod.rs b/src/ui/sonarr_ui/history/mod.rs new file mode 100644 index 0000000..cec5580 --- /dev/null +++ b/src/ui/sonarr_ui/history/mod.rs @@ -0,0 +1,347 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; +use crate::models::sonarr_models::{SonarrHistoryData, SonarrHistoryEventType, SonarrHistoryItem}; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{draw_input_box_popup, draw_popup_over, DrawUi}; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "history_ui_tests.rs"] +mod history_ui_tests; + +pub(super) struct HistoryUi; + +impl DrawUi for HistoryUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return HISTORY_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::History | ActiveSonarrBlock::HistorySortPrompt => { + draw_history_table(f, app, area) + } + ActiveSonarrBlock::SearchHistory => draw_popup_over( + f, + app, + area, + draw_history_table, + draw_history_search_box, + Size::InputBox, + ), + ActiveSonarrBlock::SearchHistoryError => { + let popup = Popup::new(Message::new("History item not found!")).size(Size::Message); + + draw_history_table(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::FilterHistory => draw_popup_over( + f, + app, + area, + draw_history_table, + draw_filter_history_box, + Size::InputBox, + ), + ActiveSonarrBlock::FilterHistoryError => { + let popup = Popup::new(Message::new( + "No history items found matching the given filter!", + )) + .size(Size::Message); + + draw_history_table(f, app, area); + f.render_widget(popup, f.area()); + } + ActiveSonarrBlock::HistoryItemDetails => { + draw_history_table(f, app, area); + draw_history_item_details_popup(f, app); + } + _ => (), + } + } + } +} + +fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.sonarr_data.history.items.is_empty() { + SonarrHistoryItem::default() + } else { + app.data.sonarr_data.history.current_selection().clone() + }; + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let history_table_footer = app + .data + .sonarr_data + .main_tabs + .get_active_tab_contextual_help(); + + 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 history_table = + ManagarrTable::new(Some(&mut app.data.sonarr_data.history), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::HistorySortPrompt) + .headers(["Source Title", "Event Type", "Language", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(12), + Constraint::Percentage(13), + Constraint::Percentage(20), + ]); + + f.render_widget(history_table, area); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.sonarr_data.history.items.is_empty() { + SonarrHistoryItem::default() + } else { + app.data.sonarr_data.history.current_selection().clone() + }; + + let line_vec = match current_selection.event_type { + SonarrHistoryEventType::Unknown => create_unknown_event_vec(current_selection), + SonarrHistoryEventType::DownloadFolderImported => { + create_download_folder_imported_event_vec(current_selection) + } + SonarrHistoryEventType::DownloadFailed => create_download_failed_event_vec(current_selection), + SonarrHistoryEventType::EpisodeFileDeleted => { + create_episode_file_deleted_event_vec(current_selection) + } + SonarrHistoryEventType::EpisodeFileRenamed => { + create_episode_file_renamed_event_vec(current_selection) + } + _ => create_no_data_event_vec(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), f.area()); +} + +fn create_unknown_event_vec(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(), + ]), + ] +} + +fn create_download_folder_imported_event_vec( + 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(), + ]), + ] +} + +fn create_download_failed_event_vec(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(), + ]), + ] +} + +fn create_episode_file_deleted_event_vec(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(), + ]), + ] +} + +fn create_episode_file_renamed_event_vec(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(), + ]), + ] +} + +fn create_no_data_event_vec(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()]), + ] +} + +fn draw_history_search_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Search", + app.data.sonarr_data.history.search.as_ref().unwrap(), + ); +} + +fn draw_filter_history_box(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_input_box_popup( + f, + area, + "Filter", + app.data.sonarr_data.history.filter.as_ref().unwrap(), + ) +} diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index 7fac977..22a5656 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -3,6 +3,7 @@ use std::{cmp, iter}; use blocklist::BlocklistUi; use chrono::{Duration, Utc}; use downloads::DownloadsUi; +use history::HistoryUi; use library::LibraryUi; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -36,6 +37,7 @@ use super::{ mod blocklist; mod downloads; +mod history; mod library; #[cfg(test)] @@ -57,6 +59,7 @@ impl DrawUi for SonarrUi { _ 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), + _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ => (), } }