diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 7c23e6d..791b002 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -107,7 +107,7 @@ pub struct DeleteSeriesParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, - pub status: String, + pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, #[serde(deserialize_with = "super::from_i64")] @@ -124,6 +124,57 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter)] +#[serde(rename_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + DownloadClientUnavailable, + Fallback, +} + +impl Display for DownloadStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let download_status = match self { + DownloadStatus::Unknown => "unknown", + DownloadStatus::Queued => "queued", + DownloadStatus::Paused => "paused", + DownloadStatus::Downloading => "downloading", + DownloadStatus::Completed => "completed", + DownloadStatus::Failed => "failed", + DownloadStatus::Warning => "warning", + DownloadStatus::Delay => "delay", + DownloadStatus::DownloadClientUnavailable => "downloadClientUnavailable", + DownloadStatus::Fallback => "fallback", + }; + write!(f, "{download_status}") + } +} + +impl<'a> EnumDisplayStyle<'a> for DownloadStatus { + fn to_display_str(self) -> &'a str { + match self { + DownloadStatus::Unknown => "Unknown", + DownloadStatus::Queued => "Queued", + DownloadStatus::Paused => "Paused", + DownloadStatus::Downloading => "Downloading", + DownloadStatus::Completed => "Completed", + DownloadStatus::Failed => "Failed", + DownloadStatus::Warning => "Warning", + DownloadStatus::Delay => "Delay", + DownloadStatus::DownloadClientUnavailable => "Download Client Unavailable", + DownloadStatus::Fallback => "Fallback", + } + } +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 2ba98eb..ff55030 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -10,10 +10,10 @@ mod tests { RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, - Episode, EpisodeFile, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, - SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, - SonarrTaskName, SystemStatus, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, + DownloadsResponse, Episode, EpisodeFile, IndexerSettings, Series, SeriesMonitor, + SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, + SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -119,6 +119,40 @@ mod tests { assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); } + #[test] + fn test_download_status_display() { + assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); + assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); + assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); + assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); + assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); + assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); + assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); + assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_string(), + "downloadClientUnavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); + } + + #[test] + fn test_download_status_to_display_str() { + assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); + assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); + assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); + assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); + assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); + assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); + assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_display_str(), + "Download Client Unavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); + } + #[test] fn test_sonarr_history_event_type_display() { assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 48e75b1..2c8ad65 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -31,7 +31,7 @@ use crate::{ network::RequestMethod, utils::convert_to_gb, }; - +use crate::models::sonarr_models::DownloadStatus; use super::{Network, NetworkEvent, NetworkResource}; #[cfg(test)] #[path = "sonarr_network_tests.rs"] @@ -2642,11 +2642,11 @@ fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_ .iter() .find(|&download| download.episode_id == episode_id) { - if download.status == "downloading" { + if download.status == DownloadStatus::Downloading { return "Downloading".to_owned(); } - if download.status == "completed" { + if download.status == DownloadStatus::Completed { return "Awaiting Import".to_owned(); } } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 42db1df..b87ebda 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -15,10 +15,7 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; - use crate::models::sonarr_models::{ - AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType, - }; + use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadStatus, EditSeriesParams, IndexerSettings, SeriesMonitor, SonarrHistoryEventType}; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; @@ -7167,7 +7164,7 @@ mod test { false, &[DownloadRecord { episode_id: 1, - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, ..DownloadRecord::default() }], 1 @@ -7183,7 +7180,7 @@ mod test { false, &[DownloadRecord { episode_id: 1, - status: "completed".to_owned(), + status: DownloadStatus::Completed, ..DownloadRecord::default() }], 1 @@ -7231,7 +7228,7 @@ mod test { fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, episode_id: 1, size: 3543348019f64, diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 6ef4fc6..7cfe147 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -13,9 +13,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_ use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_bottom_border, layout_block_top_border, -}; +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; @@ -555,13 +553,3 @@ fn style_from_download_status(download_status: &str, is_monitored: bool, status: _ => Style::new().downloaded(), } } - -fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) -> Text<'_> { - if seeders == 0 { - text.failure() - } else if seeders < leechers { - text.warning() - } else { - text.success() - } -} diff --git a/src/ui/radarr_ui/library/movie_details_ui_tests.rs b/src/ui/radarr_ui/library/movie_details_ui_tests.rs index 485594b..18aa2bc 100644 --- a/src/ui/radarr_ui/library/movie_details_ui_tests.rs +++ b/src/ui/radarr_ui/library/movie_details_ui_tests.rs @@ -2,13 +2,11 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::style::Style; - use ratatui::text::Text; use rstest::rstest; use strum::IntoEnumIterator; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; - use crate::ui::radarr_ui::library::movie_details_ui::{ - decorate_peer_style, style_from_download_status, MovieDetailsUi, + use crate::ui::radarr_ui::library::movie_details_ui::{style_from_download_status, MovieDetailsUi, }; use crate::ui::styles::ManagarrStyle; use crate::ui::DrawUi; @@ -43,36 +41,4 @@ mod tests { expected_style ); } - - #[rstest] - #[case(0, 0, PeerStyle::Failure)] - #[case(1, 2, PeerStyle::Warning)] - #[case(4, 2, PeerStyle::Success)] - fn test_decorate_peer_style( - #[case] seeders: u64, - #[case] leechers: u64, - #[case] expected_style: PeerStyle, - ) { - let text = Text::from("test"); - match expected_style { - PeerStyle::Failure => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.failure() - ), - PeerStyle::Warning => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.warning() - ), - PeerStyle::Success => assert_eq!( - decorate_peer_style(seeders, leechers, text.clone()), - text.success() - ), - } - } - - enum PeerStyle { - Failure, - Warning, - Success, - } } diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 88191ba..874b38f 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, ADD_SERIES_BLOCKS, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - SERIES_DETAILS_BLOCKS, + SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -16,8 +16,7 @@ mod tests { use crate::models::sonarr_models::{Season, SeasonStatistics}; use crate::{ - models::sonarr_models::Series, - ui::sonarr_ui::library::decorate_series_row_with_style, + models::sonarr_models::Series, ui::sonarr_ui::library::decorate_series_row_with_style, }; #[test] @@ -28,6 +27,7 @@ mod tests { library_ui_blocks.extend(DELETE_SERIES_BLOCKS); library_ui_blocks.extend(EDIT_SERIES_BLOCKS); library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); + library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if library_ui_blocks.contains(&active_sonarr_block) { @@ -39,8 +39,7 @@ mod tests { } #[test] - fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present( - ) { + fn test_decorate_row_with_style_downloaded_when_ended_and_all_monitored_episodes_are_present() { let seasons = vec![ Season { monitored: false, diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index e50a994..3cfe084 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -35,6 +35,7 @@ mod series_details_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; +mod season_details_ui; pub(super) struct LibraryUi; @@ -80,7 +81,10 @@ 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), + _ if SeriesDetailsUi::accepts(route) => { + draw_library(f, app, area); + SeriesDetailsUi::draw(f, app, area) + }, Route::Sonarr(active_sonarr_block, _) if LIBRARY_BLOCKS.contains(&active_sonarr_block) => { series_ui_matchers(active_sonarr_block) } diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index e69de29..8b5765a 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -0,0 +1,535 @@ +use crate::app::App; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; +use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, +}; +use crate::models::Route; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + borderless_block, decorate_peer_style, get_width_from_percentage, 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::popup::{Popup, Size}; +use crate::ui::{draw_popup, draw_tabs, DrawUi}; +use crate::utils::convert_to_gb; +use chrono::Utc; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use ratatui::Frame; + +#[cfg(test)] +#[path = "season_details_ui_tests.rs"] +mod season_details_ui_tests; + +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); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.sonarr_data.season_details_modal.is_some() { + if let Route::Sonarr(active_sonarr_block, _) = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs + .get_active_route() + { + let draw_season_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + "Season Details", + &app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_details_tabs, + ); + draw_season_details(f, app, content_area); + + match active_sonarr_block { + ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for season packs for the season: Season {}", + app.data.sonarr_data.seasons.current_selection().season_number + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Season 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::DeleteEpisodeFilePrompt => { + let prompt = format!( + "Do you really want to delete this episode: \n{}?", + app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .current_selection() + .title + .as_ref() + .unwrap_or(&String::new()) + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Episode") + .prompt(&prompt) + .yes_no_value(app.data.sonarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt => { + draw_manual_season_search_confirm_prompt(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_season_details_popup, Size::Large); + } + } + } +} + +pub fn draw_season_details(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 Route::Sonarr(active_sonarr_block, _) = + season_details_modal.season_details_tabs.get_active_route() + { + match active_sonarr_block { + ActiveSonarrBlock::SeasonDetails => draw_episodes_table(f, app, area), + ActiveSonarrBlock::SeasonHistory => draw_episode_history_table(f, app, area), + ActiveSonarrBlock::ManualSeasonSearch => draw_season_releases(f, app, area), + _ => (), + } + } + } +} + +fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let help_footer = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details modal is unpopulated") + .season_details_tabs + .get_active_tab_contextual_help(); + let episode_files = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details modal is unpopulated") + .episode_files + .items + .clone(); + let content = Some( + &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is unpopulated") + .episodes, + ); + let downloads_vec = &app.data.sonarr_data.downloads.items; + + let episode_row_mapping = |episode: &Episode| { + let Episode { + episode_number, + title, + air_date_utc, + episode_file_id, + .. + } = episode; + let episode_file = episode_files + .iter() + .find(|episode_file| episode_file.id == *episode_file_id); + let (quality_profile, size_on_disk) = if let Some(episode_file) = episode_file { + ( + episode_file.quality.quality.name.to_owned(), + episode_file.size, + ) + } else { + (String::new(), 0) + }; + + let episode_monitored = if episode.monitored { "🏷" } else { "" }; + let size = convert_to_gb(size_on_disk); + let air_date = if let Some(air_date) = air_date_utc.as_ref() { + air_date.to_string() + } else { + String::new() + }; + + decorate_with_row_style( + downloads_vec, + episode, + Row::new(vec![ + Cell::from(episode_monitored.to_owned()), + Cell::from(episode_number.to_string()), + Cell::from(title.clone().unwrap_or_default()), + Cell::from(air_date), + Cell::from(format!("{size:.2} GB")), + Cell::from(quality_profile), + ]), + ) + }; + let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; + let season_table = ManagarrTable::new(content, episode_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(help_footer) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeason) + .search_produced_empty_results(active_sonarr_block == ActiveSonarrBlock::SearchSeasonError) + .headers([ + "🏷", + "#", + "Title", + "Air Date", + "Size on Disk", + "Quality Profile", + ]) + .constraints([ + Constraint::Percentage(4), + Constraint::Percentage(4), + Constraint::Percentage(50), + Constraint::Percentage(19), + Constraint::Percentage(10), + Constraint::Percentage(12), + ]); + + if is_searching { + season_table.show_cursor(f, area); + } + + f.render_widget(season_table, 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 => { + let current_selection = if season_details_modal.season_history.is_empty() { + SonarrHistoryItem::default() + } else { + season_details_modal + .season_history + .current_selection() + .clone() + }; + let season_history_table_footer = season_details_modal + .season_details_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 season_history_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history; + let history_table = + ManagarrTable::new(Some(&mut season_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(season_history_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::SeasonHistorySortPrompt) + .searching(active_sonarr_block == ActiveSonarrBlock::SearchSeasonHistory) + .search_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::SearchSeasonHistoryError, + ) + .filtering(active_sonarr_block == ActiveSonarrBlock::FilterSeasonHistory) + .filter_produced_empty_results( + active_sonarr_block == ActiveSonarrBlock::FilterSeasonHistoryError, + ) + .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.sonarr_data.season_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_season_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 => { + let current_selection = if season_details_modal.season_releases.is_empty() { + SonarrRelease::default() + } else { + season_details_modal + .season_releases + .current_selection() + .clone() + }; + let season_release_table_footer = season_details_modal + .season_details_tabs + .get_active_tab_contextual_help(); + + if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { + let season_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::ManualSeasonSearchConfirmPrompt, + 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 season_release_table = &mut app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases; + let release_table = + ManagarrTable::new(Some(&mut season_release_table), season_release_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .footer(season_release_table_footer) + .sorting(active_sonarr_block == ActiveSonarrBlock::ManualSeasonSearchSortPrompt) + .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(release_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.sonarr_data.season_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_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.radarr_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.radarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn decorate_with_row_style<'a>( + downloads_vec: &[DownloadRecord], + episode: &Episode, + row: Row<'a>, +) -> Row<'a> { + if !episode.has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.episode_id == episode.id) + { + if download.status == DownloadStatus::Downloading { + return row.downloading(); + } + + if download.status == DownloadStatus::Completed { + return row.awaiting_import(); + } + } + + if !episode.monitored { + return row.unmonitored_missing(); + } + + if let Some(air_date) = episode.air_date_utc.as_ref() { + if air_date > &Utc::now() { + return row.unreleased(); + } + } + + return row.missing(); + } + + if !episode.monitored { + row.unmonitored() + } else { + row.downloaded() + } +} 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 e69de29..64264fc 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, + }; + use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; + use crate::ui::DrawUi; + + #[test] + fn test_season_details_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SEASON_DETAILS_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 63d6ca5..40dc905 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -28,7 +28,8 @@ 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::ui::{draw_popup, draw_popup_over, draw_tabs, DrawUi}; +use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; use crate::utils::convert_to_gb; use super::draw_library; @@ -42,14 +43,15 @@ 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); + return SeasonDetailsUi::accepts(route) || 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 route = app.get_current_route(); + if let Route::Sonarr(active_sonarr_block, _) = 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), @@ -105,14 +107,23 @@ impl DrawUi for SeriesDetailsUi { }; }; - draw_popup_over( - f, - app, - area, - draw_library, - draw_series_details_popup, - Size::XXLarge, - ); + match route { + _ if SeasonDetailsUi::accepts(route) => { + draw_popup(f, app, draw_series_details_popup, Size::XXLarge); + SeasonDetailsUi::draw(f, app, area); + }, + Route::Sonarr(active_sonarr_block, _) if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) => { + draw_popup_over( + f, + app, + area, + draw_library, + draw_series_details_popup, + Size::XXLarge, + ); + } + _ => (), + } } } } @@ -364,7 +375,7 @@ fn draw_series_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } _ => f.render_widget( LoadingBlock::new( - app.is_loading || app.data.radarr_data.movie_details_modal.is_none(), + app.is_loading || app.data.sonarr_data.seasons.is_empty(), layout_block_top_border(), ), 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 index 7dd2f8d..0dc52da 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -2,16 +2,17 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, - }; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS}; use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; use crate::ui::DrawUi; #[test] fn test_series_details_ui_accepts() { + let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); + blocks.extend(SEASON_DETAILS_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if 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/utils.rs b/src/ui/utils.rs index 3b206db..3a7aba9 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -154,3 +154,13 @@ pub(super) fn convert_to_minutes_hours_days(time: i64) -> String { } } } + +pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) -> Text<'_> { + if seeders == 0 { + text.failure() + } else if seeders < leechers { + text.warning() + } else { + text.success() + } +} diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index 47ab277..f0b2e17 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -5,13 +5,8 @@ mod test { use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, ListItem}; - - use crate::ui::utils::{ - borderless_block, centered_rect, convert_to_minutes_hours_days, get_width_from_percentage, - layout_block, layout_block_bottom_border, layout_block_top_border, - layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, - style_log_list_item, title_block, title_block_centered, title_style, - }; + use rstest::rstest; + use crate::ui::utils::{borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border, layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, style_log_list_item, title_block, title_block_centered, title_style}; #[test] fn test_layout_block() { @@ -238,6 +233,39 @@ mod test { assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); } + #[rstest] + #[case(0, 0, PeerStyle::Failure)] + #[case(1, 2, PeerStyle::Warning)] + #[case(4, 2, PeerStyle::Success)] + fn test_decorate_peer_style( + #[case] seeders: u64, + #[case] leechers: u64, + #[case] expected_style: PeerStyle, + ) { + use crate::ui::styles::ManagarrStyle; + let text = Text::from("test"); + match expected_style { + PeerStyle::Failure => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.failure() + ), + PeerStyle::Warning => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.warning() + ), + PeerStyle::Success => assert_eq!( + decorate_peer_style(seeders, leechers, text.clone()), + text.success() + ), + } + } + + enum PeerStyle { + Failure, + Warning, + Success, + } + fn rect() -> Rect { Rect { x: 0,