From 12eb453fc7916c31f879562a6bea2de433b0f716 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 12 Dec 2024 16:25:02 -0700 Subject: [PATCH] feat(ui): Support for the episode details UI --- src/app/sonarr/mod.rs | 6 + src/app/sonarr/sonarr_context_clues.rs | 15 - src/app/sonarr/sonarr_tests.rs | 28 +- .../library/season_details_handler.rs | 16 + .../library/season_details_handler_tests.rs | 107 +++- src/models/servarr_data/sonarr/modals.rs | 4 +- .../servarr_data/sonarr/modals_tests.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 12 + .../servarr_data/sonarr/sonarr_data_tests.rs | 22 +- .../sonarr_ui/library/episode_details_ui.rs | 594 ++++++++++++++++++ .../library/episode_details_ui_tests.rs | 18 + src/ui/sonarr_ui/library/library_ui_tests.rs | 6 +- src/ui/sonarr_ui/library/mod.rs | 1 + src/ui/sonarr_ui/library/season_details_ui.rs | 12 +- .../library/season_details_ui_tests.rs | 9 +- src/ui/sonarr_ui/library/series_details_ui.rs | 3 +- .../library/series_details_ui_tests.rs | 3 +- 17 files changed, 800 insertions(+), 60 deletions(-) create mode 100644 src/ui/sonarr_ui/library/episode_details_ui.rs create mode 100644 src/ui/sonarr_ui/library/episode_details_ui_tests.rs diff --git a/src/app/sonarr/mod.rs b/src/app/sonarr/mod.rs index 30f042a..d8ae5fb 100644 --- a/src/app/sonarr/mod.rs +++ b/src/app/sonarr/mod.rs @@ -65,6 +65,12 @@ impl<'a> App<'a> { } } ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => { + self + .dispatch_network_event(SonarrEvent::GetEpisodes(None).into()) + .await; + self + .dispatch_network_event(SonarrEvent::GetDownloads.into()) + .await; self .dispatch_network_event(SonarrEvent::GetEpisodeDetails(None).into()) .await; diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 90caa32..ece0638 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -145,21 +145,6 @@ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; -pub static EPISODE_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), - (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), - ( - DEFAULT_KEYBINDINGS.auto_search, - DEFAULT_KEYBINDINGS.auto_search.desc, - ), - (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), -]; - pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), diff --git a/src/app/sonarr/sonarr_tests.rs b/src/app/sonarr/sonarr_tests.rs index 4cb3dce..28942ab 100644 --- a/src/app/sonarr/sonarr_tests.rs +++ b/src/app/sonarr/sonarr_tests.rs @@ -176,6 +176,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodes(None).into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() @@ -193,6 +201,14 @@ mod tests { .await; assert!(app.is_loading); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetEpisodes(None).into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + SonarrEvent::GetDownloads.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), SonarrEvent::GetEpisodeDetails(None).into() @@ -221,8 +237,10 @@ mod tests { #[tokio::test] async fn test_dispatch_by_manual_episode_search_block() { let (mut app, mut sync_network_rx) = construct_app_unit(); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episode_details_modal = Some(EpisodeDetailsModal::default()); + let season_details_modal = SeasonDetailsModal { + episode_details_modal: Some(EpisodeDetailsModal::default()), + ..SeasonDetailsModal::default() + }; app.data.sonarr_data.season_details_modal = Some(season_details_modal); app @@ -261,8 +279,10 @@ mod tests { episode_details_modal .episode_releases .set_items(vec![SonarrRelease::default()]); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episode_details_modal = Some(episode_details_modal); + let season_details_modal = SeasonDetailsModal { + episode_details_modal: Some(episode_details_modal), + ..SeasonDetailsModal::default() + }; app.data.sonarr_data.season_details_modal = Some(season_details_modal); app diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 88b53c3..6f90701 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -212,6 +212,22 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler fn handle_submit(&mut self) { match self.active_sonarr_block { + ActiveSonarrBlock::SeasonDetails + if self.app.data.sonarr_data.season_details_modal.is_some() + && !self + .app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()) + } ActiveSonarrBlock::SeasonHistory => self .app .push_navigation_stack(ActiveSonarrBlock::SeasonHistoryDetails.into()), diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs index 80f525b..8ad0954 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -168,6 +168,58 @@ mod tests { const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + #[test] + fn test_season_details_submit() { + let mut app = App::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.data.sonarr_data = create_test_sonarr_data(); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::EpisodeDetails.into() + ); + } + + #[test] + fn test_season_details_submit_no_op_on_empty_episodes_table() { + let mut app = App::default(); + app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + + #[test] + fn test_season_details_submit_no_op_when_not_ready() { + let mut app = App::default(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::with(SUBMIT_KEY, &mut app, ActiveSonarrBlock::SeasonDetails, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeasonDetails.into() + ); + } + #[test] fn test_season_history_submit() { let mut app = App::default(); @@ -418,7 +470,13 @@ mod tests { ..StatefulTable::default() }; season_history.set_items(vec![SonarrHistoryItem::default()]); - app.data.sonarr_data.season_details_modal.as_mut().unwrap().season_history = season_history; + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history = season_history; app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); @@ -457,22 +515,23 @@ mod tests { .filtered_state .is_none()); } - + #[rstest] fn test_season_details_tabs_esc( #[values( - ActiveSonarrBlock::SeasonDetails, - ActiveSonarrBlock::SeasonHistory, - ActiveSonarrBlock::ManualSeasonSearch - )] active_sonarr_block: ActiveSonarrBlock + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] + active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); app.data.sonarr_data = create_test_sonarr_data(); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(active_sonarr_block.into()); - + SeasonDetailsHandler::with(ESC_KEY, &mut app, active_sonarr_block, None).handle(); - + assert_eq!( app.get_current_route(), ActiveSonarrBlock::SeriesDetails.into() @@ -489,7 +548,11 @@ mod tests { #[rstest] fn test_auto_search_key( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -502,7 +565,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!( app.get_current_route(), @@ -512,7 +575,11 @@ mod tests { #[rstest] fn test_auto_search_key_no_op_when_not_ready( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -525,14 +592,18 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); } #[rstest] fn test_refresh_key( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -546,7 +617,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(app.is_routing); @@ -554,7 +625,11 @@ mod tests { #[rstest] fn test_refresh_key_no_op_when_not_ready( - #[values(ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, ActiveSonarrBlock::ManualSeasonSearch)] + #[values( + ActiveSonarrBlock::SeasonDetails, + ActiveSonarrBlock::SeasonHistory, + ActiveSonarrBlock::ManualSeasonSearch + )] active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::default(); @@ -569,7 +644,7 @@ mod tests { active_sonarr_block, None, ) - .handle(); + .handle(); assert_eq!(app.get_current_route(), active_sonarr_block.into()); assert!(!app.is_routing); diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index aeffb89..230a692 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -5,7 +5,7 @@ use crate::{ context_clues::build_context_clue_string, sonarr::sonarr_context_clues::{ DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, - EPISODE_HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, + MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, }, @@ -288,7 +288,7 @@ impl Default for EpisodeDetailsModal { TabRoute { title: "History", route: ActiveSonarrBlock::EpisodeHistory.into(), - help: build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES), + help: build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES), contextual_help: Some(build_context_clue_string(&DETAILS_CONTEXTUAL_CONTEXT_CLUES)), }, TabRoute { diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index d51a47b..aebffb8 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::app::context_clues::build_context_clue_string; use crate::app::sonarr::sonarr_context_clues::{ - DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, EPISODE_HISTORY_CONTEXT_CLUES, + DETAILS_CONTEXTUAL_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, @@ -275,7 +275,7 @@ mod tests { ); assert_str_eq!( episode_details_modal.episode_details_tabs.tabs[1].help, - build_context_clue_string(&EPISODE_HISTORY_CONTEXT_CLUES) + build_context_clue_string(&EPISODE_DETAILS_CONTEXT_CLUES) ); assert_eq!( episode_details_modal.episode_details_tabs.tabs[1].contextual_help, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 5137fd0..04bab77 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -245,6 +245,7 @@ pub enum ActiveSonarrBlock { EpisodeDetails, EpisodeFile, EpisodeHistory, + EpisodeHistoryDetails, EpisodesSortPrompt, FilterEpisodes, FilterEpisodesError, @@ -351,6 +352,17 @@ pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::DeleteEpisodeFilePrompt, ]; +pub static EPISODE_DETAILS_BLOCKS: [ActiveSonarrBlock; 8] = [ + ActiveSonarrBlock::EpisodeDetails, + ActiveSonarrBlock::EpisodeHistory, + ActiveSonarrBlock::EpisodeHistoryDetails, + ActiveSonarrBlock::EpisodeFile, + ActiveSonarrBlock::ManualEpisodeSearch, + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + ActiveSonarrBlock::ManualEpisodeSearchSortPrompt, + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, +]; + 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 b78beb4..e511d95 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -222,14 +222,7 @@ mod tests { } mod active_sonarr_block_tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, - DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_INDEXER_BLOCKS, - 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, - SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, BLOCKLIST_BLOCKS, DELETE_SERIES_BLOCKS, DELETE_SERIES_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, EPISODE_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS}; #[test] fn test_library_blocks_contents() { @@ -625,5 +618,18 @@ mod tests { assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualSeasonSearchSortPrompt)); assert!(SEASON_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::DeleteEpisodeFilePrompt)); } + + #[test] + fn test_episode_details_blocks_contents() { + assert_eq!(EPISODE_DETAILS_BLOCKS.len(), 8); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeDetails)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeHistory)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeHistoryDetails)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::EpisodeFile)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearch)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearchSortPrompt)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt)); + assert!(EPISODE_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::AutomaticallySearchEpisodePrompt)); + } } } diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs new file mode 100644 index 0000000..ea3d7eb --- /dev/null +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -0,0 +1,594 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, +}; +use crate::models::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, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border, + layout_block_top_border, +}; +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, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; +use chrono::Utc; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "episode_details_ui_tests.rs"] +mod episode_details_ui_tests; + +pub(super) struct EpisodeDetailsUi; + +impl DrawUi for EpisodeDetailsUi { + fn accepts(route: Route) -> bool { + if let Route::Sonarr(active_sonarr_block, _) = route { + return EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if season_details_modal.episode_details_modal.is_some() { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let draw_episode_details_popup = + |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + "Episode Details", + &app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs, + ); + draw_episode_details_tabs(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::AutomaticallySearchEpisodePrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for the episode: {}", + app.data.sonarr_data.season_details_modal.as_ref().unwrap().episodes.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Episode Search") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt => { + draw_manual_episode_search_confirm_prompt(f, app); + } + ActiveSonarrBlock::EpisodeHistoryDetails => { + draw_history_item_details_popup(f, app, popup_area); + } + _ => (), + } + }; + + draw_popup(f, app, draw_episode_details_popup, Size::Large); + } + } + } + } +} + +pub fn draw_episode_details_tabs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Some(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if let Route::Sonarr(active_sonarr_block, _) = episode_details_modal + .episode_details_tabs + .get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::EpisodeDetails => draw_episode_details(f, app, area), + ActiveSonarrBlock::EpisodeHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::EpisodeFile => draw_file_info(f, app, area), + ActiveSonarrBlock::ManualEpisodeSearch => draw_episode_releases(f, app, area), + _ => (), + } + } + } + } +} + +fn draw_episode_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = layout_block_top_border(); + + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let episode = season_details_modal.episodes.current_selection().clone(); + let episode_details = &episode_details_modal.episode_details; + let download = app + .data + .sonarr_data + .downloads + .items + .iter() + .find(|&download| download.episode_id == episode.id); + let text = Text::from( + episode_details + .items + .iter() + .map(|line| { + let split = line.split(':').collect::>(); + let title = format!("{}:", split[0]); + let style = style_from_status(download, &episode); + + Line::from(vec![ + title.bold().style(style), + Span::styled(split[1..].join(":"), style), + ]) + }) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((episode_details.offset, 0)); + + f.render_widget(paragraph, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none(), + block, + ), + area, + ), + } +} + +fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) => match season_details_modal.episode_details_modal.as_ref() { + Some(episode_details_modal) + if !episode_details_modal.file_details.is_empty() && !app.is_loading => + { + let file_info = episode_details_modal.file_details.to_owned(); + let audio_details = episode_details_modal.audio_details.to_owned(); + let video_details = episode_details_modal.video_details.to_owned(); + let [file_details_title_area, file_details_area, audio_details_title_area, audio_details_area, video_details_title_area, video_details_area] = + Layout::vertical([ + Constraint::Length(2), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Length(1), + Constraint::Length(7), + ]) + .areas(area); + + let file_details_title_paragraph = + Paragraph::new("File Details".bold()).block(layout_block_top_border()); + let audio_details_title_paragraph = + Paragraph::new("Audio Details".bold()).block(borderless_block()); + let video_details_title_paragraph = + Paragraph::new("Video Details".bold()).block(borderless_block()); + + let file_details = Text::from(file_info); + let audio_details = Text::from(audio_details); + let video_details = Text::from(video_details); + + let file_details_paragraph = Paragraph::new(file_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let audio_details_paragraph = Paragraph::new(audio_details) + .block(layout_block_bottom_border()) + .wrap(Wrap { trim: false }); + let video_details_paragraph = Paragraph::new(video_details) + .block(borderless_block()) + .wrap(Wrap { trim: false }); + + f.render_widget(file_details_title_paragraph, file_details_title_area); + f.render_widget(file_details_paragraph, file_details_area); + f.render_widget(audio_details_title_paragraph, audio_details_title_area); + f.render_widget(audio_details_paragraph, audio_details_area); + f.render_widget(video_details_title_paragraph, video_details_title_area); + f.render_widget(video_details_paragraph, video_details_area); + } + _ => (), + }, + _ => f.render_widget( + LoadingBlock::new(app.is_loading, layout_block_top_border()), + area, + ), + } +} + +fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let current_selection = if episode_details_modal.episode_history.is_empty() { + SonarrHistoryItem::default() + } else { + episode_details_modal + .episode_history + .current_selection() + .clone() + }; + let episode_history_table_footer = episode_details_modal + .episode_details_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 mut episode_history_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history; + let history_table = + ManagarrTable::new(Some(&mut episode_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(episode_history_table_footer) + .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); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_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(season_details_modal) = app.data.sonarr_data.season_details_modal.as_ref() { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + if episode_details_modal.episode_history.is_empty() { + SonarrHistoryItem::default() + } else { + episode_details_modal + .episode_history + .current_selection() + .clone() + } + } else { + SonarrHistoryItem::default() + } + } 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); +} + +fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.sonarr_data.season_details_modal.as_ref() { + Some(season_details_modal) if !app.is_loading => { + if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { + let (current_selection, is_empty) = if episode_details_modal.episode_releases.is_empty() { + (SonarrRelease::default(), true) + } else { + ( + episode_details_modal + .episode_releases + .current_selection() + .clone(), + episode_details_modal.episode_releases.is_empty(), + ) + }; + let episode_release_table_footer = episode_details_modal + .episode_details_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let episode_release_row_mapping = |release: &SonarrRelease| { + let SonarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + languages, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 30), + current_selection == *release + && active_sonarr_block != ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, + app.tick_count % app.ticks_until_scroll == 0, + ); + let size = convert_to_gb(*size); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders.clone().unwrap().as_u64().unwrap(); + let leechers = leechers.clone().unwrap().as_u64().unwrap(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let language = if languages.is_some() { + languages.clone().unwrap()[0].name.clone() + } else { + String::new() + }; + let quality = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(format!("{size:.1} GB")), + Cell::from(peers), + Cell::from(language), + Cell::from(quality), + ]) + .primary() + }; + let mut episode_release_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases; + let release_table = ManagarrTable::new( + Some(&mut episode_release_table), + episode_release_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .footer(episode_release_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::ManualEpisodeSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(30), + Constraint::Percentage(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(7), + Constraint::Percentage(10), + ]); + + f.render_widget(release_table, area); + } + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_episode_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .current_selection(); + let title = if current_selection.rejected { + "Download Rejected Release" + } else { + "Download Release" + }; + let prompt = if current_selection.rejected { + format!( + "Do you really want to download the rejected release: {}?", + ¤t_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + ¤t_selection.title.text + ) + }; + + if current_selection.rejected { + let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; + let mut rejections_spans = current_selection + .rejections + .clone() + .unwrap_or_default() + .iter() + .map(|item| Line::from(format!("• {item}").primary().bold())) + .collect::>>(); + lines_vec.append(&mut rejections_spans); + + let content_paragraph = Paragraph::new(lines_vec) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .left_aligned(); + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .content(content_paragraph) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); + } else { + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn style_from_status(download: Option<&DownloadRecord>, episode: &Episode) -> Style { + if !episode.has_file { + if let Some(download) = download { + if download.status == DownloadStatus::Downloading { + return Style::new().downloading(); + } + + if download.status == DownloadStatus::Completed { + return Style::new().awaiting_import(); + } + } + if !episode.monitored { + return Style::new().unmonitored_missing(); + } + + if let Some(air_date) = episode.air_date_utc.as_ref() { + if air_date > &Utc::now() { + return Style::new().unreleased(); + } + } + + return Style::new().missing(); + } + + if !episode.monitored { + Style::new().unmonitored() + } else { + Style::new().downloaded() + } +} diff --git a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs new file mode 100644 index 0000000..21fea80 --- /dev/null +++ b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs @@ -0,0 +1,18 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; + use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; + use crate::ui::DrawUi; + use strum::IntoEnumIterator; + + #[test] + fn test_episode_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block) { + assert!(EpisodeDetailsUi::accepts(active_sonarr_block.into())); + } else { + assert!(!EpisodeDetailsUi::accepts(active_sonarr_block.into())); + } + }); + } +} \ No newline at end of file diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 874b38f..a3d9847 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -1,9 +1,6 @@ #[cfg(test)] mod tests { - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, }; @@ -28,6 +25,7 @@ mod tests { library_ui_blocks.extend(EDIT_SERIES_BLOCKS); library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); + library_ui_blocks.extend(EPISODE_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_ui_blocks.contains(&active_sonarr_block) { diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 7fb879d..b805488 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -36,6 +36,7 @@ mod series_details_ui; #[path = "library_ui_tests.rs"] mod library_ui_tests; mod season_details_ui; +mod episode_details_ui; pub(super) struct LibraryUi; diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 42191ac..97885ec 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -27,6 +27,7 @@ use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Style, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; +use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; #[cfg(test)] #[path = "season_details_ui_tests.rs"] @@ -37,13 +38,14 @@ pub(super) struct SeasonDetailsUi; impl DrawUi for SeasonDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block); + return EpisodeDetailsUi::accepts(route) || SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block); } false } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); if app.data.sonarr_data.season_details_modal.is_some() { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { @@ -111,6 +113,10 @@ impl DrawUi for SeasonDetailsUi { }; draw_popup(f, app, draw_season_details_popup, Size::XLarge); + + if EpisodeDetailsUi::accepts(route) { + EpisodeDetailsUi::draw(f, app, _area); + } } } } @@ -123,7 +129,7 @@ pub fn draw_season_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { { match active_sonarr_block { ActiveSonarrBlock::SeasonDetails => draw_episodes_table(f, app, area), - ActiveSonarrBlock::SeasonHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::SeasonHistory => draw_season_history_table(f, app, area), ActiveSonarrBlock::ManualSeasonSearch => draw_season_releases(f, app, area), _ => (), } @@ -234,7 +240,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } -fn draw_episode_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { +fn draw_season_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.sonarr_data.season_details_modal.as_ref() { Some(season_details_modal) if !app.is_loading => { let current_selection = if season_details_modal.season_history.is_empty() { diff --git a/src/ui/sonarr_ui/library/season_details_ui_tests.rs b/src/ui/sonarr_ui/library/season_details_ui_tests.rs index 64264fc..ea62998 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -2,16 +2,17 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::ui::DrawUi; #[test] fn test_season_details_ui_accepts() { + let mut blocks = SEASON_DETAILS_BLOCKS.clone().to_vec(); + blocks.extend(EPISODE_DETAILS_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if blocks.contains(&active_sonarr_block) { assert!(SeasonDetailsUi::accepts(active_sonarr_block.into())); } else { assert!(!SeasonDetailsUi::accepts(active_sonarr_block.into())); diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index ea135e1..159440b 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -30,6 +30,7 @@ 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, draw_tabs, DrawUi}; +use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::utils::convert_to_gb; #[cfg(test)] @@ -41,7 +42,7 @@ pub(super) struct SeriesDetailsUi; impl DrawUi for SeriesDetailsUi { fn accepts(route: Route) -> bool { if let Route::Sonarr(active_sonarr_block, _) = route { - return SeasonDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); + return SeasonDetailsUi::accepts(route) || EpisodeDetailsUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block); } false diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs index 0dc52da..ba960e7 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -2,7 +2,7 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; use crate::ui::DrawUi; @@ -10,6 +10,7 @@ mod tests { fn test_series_details_ui_accepts() { let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); blocks.extend(SEASON_DETAILS_BLOCKS); + blocks.extend(EPISODE_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if blocks.contains(&active_sonarr_block) {