From daf08c10cc59481672de3325eb9631ce56fcab9e Mon Sep 17 00:00:00 2001 From: Dark-Alex-17 Date: Tue, 8 Aug 2023 10:50:04 -0600 Subject: [PATCH] Added tabs and navigation for movie info --- src/app/mod.rs | 5 + src/app/radarr.rs | 40 ++++- src/handlers/radarr_handler.rs | 39 +++- src/network/radarr_network.rs | 251 +++++++++++++++----------- src/ui/mod.rs | 95 ++++++++-- src/ui/radarr_ui.rs | 320 +++++++++++++++++++-------------- src/ui/utils.rs | 33 +++- 7 files changed, 520 insertions(+), 263 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e81f0c..04543d7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -112,6 +112,11 @@ impl App { } } + pub fn pop_and_push_navigation_stack(&mut self, route: Route) { + self.pop_navigation_stack(); + self.push_navigation_stack(route); + } + pub fn get_current_route(&self) -> &Route { self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) } diff --git a/src/app/radarr.rs b/src/app/radarr.rs index f0d745e..b251a42 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -5,31 +5,46 @@ use strum::EnumIter; use crate::app::models::{ScrollableText, StatefulTable, TabRoute, TabState}; use crate::app::App; -use crate::network::radarr_network::{DownloadRecord, Movie, RadarrEvent}; +use crate::network::radarr_network::{ + DiskSpace, DownloadRecord, Movie, MovieHistoryItem, RadarrEvent, +}; pub struct RadarrData { - pub free_space: u64, - pub total_space: u64, + pub disk_space_vec: Vec, pub version: String, pub start_time: DateTime, pub movies: StatefulTable, pub downloads: StatefulTable, pub quality_profile_map: HashMap, pub movie_details: ScrollableText, + pub movie_history: StatefulTable, pub main_tabs: TabState, + pub movie_info_tabs: TabState, +} + +impl RadarrData { + pub fn reset_movie_info_tab(&mut self) { + self.movie_details = ScrollableText::default(); + self.movie_history = StatefulTable::default(); + self.movie_info_tabs.index = 0; + } + + pub fn reset_main_tab_index(&mut self) { + self.main_tabs.index = 0; + } } impl Default for RadarrData { fn default() -> RadarrData { RadarrData { - free_space: u64::default(), - total_space: u64::default(), + disk_space_vec: Vec::new(), version: String::default(), start_time: DateTime::default(), movies: StatefulTable::default(), downloads: StatefulTable::default(), quality_profile_map: HashMap::default(), movie_details: ScrollableText::default(), + movie_history: StatefulTable::default(), main_tabs: TabState::new(vec![ TabRoute { title: "Library".to_owned(), @@ -40,6 +55,16 @@ impl Default for RadarrData { route: ActiveRadarrBlock::Downloads.into(), }, ]), + movie_info_tabs: TabState::new(vec![ + TabRoute { + title: "Details".to_owned(), + route: ActiveRadarrBlock::MovieDetails.into(), + }, + TabRoute { + title: "History".to_owned(), + route: ActiveRadarrBlock::MovieHistory.into(), + }, + ]), } } } @@ -53,6 +78,7 @@ pub enum ActiveRadarrBlock { Logs, Movies, MovieDetails, + MovieHistory, Downloads, SearchMovie, SortOptions, @@ -71,6 +97,10 @@ impl App { self.is_loading = true; self.dispatch(RadarrEvent::GetMovieDetails.into()).await } + ActiveRadarrBlock::MovieHistory => { + self.is_loading = true; + self.dispatch(RadarrEvent::GetMovieHistory.into()).await + } _ => (), } diff --git a/src/handlers/radarr_handler.rs b/src/handlers/radarr_handler.rs index a47e571..8bd5bec 100644 --- a/src/handlers/radarr_handler.rs +++ b/src/handlers/radarr_handler.rs @@ -1,5 +1,5 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; -use crate::app::models::{Scrollable, ScrollableText}; +use crate::app::models::{Scrollable, ScrollableText, StatefulTable}; use crate::app::radarr::ActiveRadarrBlock; use crate::{App, Key}; @@ -25,11 +25,38 @@ async fn handle_tab_action(key: Key, app: &mut App, active_radarr_block: ActiveR ActiveRadarrBlock::Movies | ActiveRadarrBlock::Downloads => match key { _ if key == DEFAULT_KEYBINDINGS.left.key => { app.data.radarr_data.main_tabs.previous(); - app.push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route().clone()); + app + .pop_and_push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route().clone()); } _ if key == DEFAULT_KEYBINDINGS.right.key => { app.data.radarr_data.main_tabs.next(); - app.push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route().clone()); + app + .pop_and_push_navigation_stack(app.data.radarr_data.main_tabs.get_active_route().clone()); + } + _ => (), + }, + ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::MovieHistory => match key { + _ if key == DEFAULT_KEYBINDINGS.left.key => { + app.data.radarr_data.movie_info_tabs.previous(); + app.pop_and_push_navigation_stack( + app + .data + .radarr_data + .movie_info_tabs + .get_active_route() + .clone(), + ); + } + _ if key == DEFAULT_KEYBINDINGS.right.key => { + app.data.radarr_data.movie_info_tabs.next(); + app.pop_and_push_navigation_stack( + app + .data + .radarr_data + .movie_info_tabs + .get_active_route() + .clone(), + ); } _ => (), }, @@ -41,6 +68,7 @@ async fn handle_scroll_up(app: &mut App, active_radarr_block: ActiveRadarrBlock) match active_radarr_block { ActiveRadarrBlock::Movies => app.data.radarr_data.movies.scroll_up(), ActiveRadarrBlock::MovieDetails => app.data.radarr_data.movie_details.scroll_up(), + ActiveRadarrBlock::MovieHistory => app.data.radarr_data.movie_history.scroll_up(), ActiveRadarrBlock::Downloads => app.data.radarr_data.downloads.scroll_up(), _ => (), } @@ -50,6 +78,7 @@ async fn handle_scroll_down(app: &mut App, active_radarr_block: ActiveRadarrBloc match active_radarr_block { ActiveRadarrBlock::Movies => app.data.radarr_data.movies.scroll_down(), ActiveRadarrBlock::MovieDetails => app.data.radarr_data.movie_details.scroll_down(), + ActiveRadarrBlock::MovieHistory => app.data.radarr_data.movie_history.scroll_down(), ActiveRadarrBlock::Downloads => app.data.radarr_data.downloads.scroll_down(), _ => (), } @@ -64,9 +93,9 @@ async fn handle_submit(app: &mut App, active_radarr_block: ActiveRadarrBlock) { async fn handle_esc(app: &mut App, active_radarr_block: ActiveRadarrBlock) { match active_radarr_block { - ActiveRadarrBlock::MovieDetails => { + ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::MovieHistory => { app.pop_navigation_stack(); - app.data.radarr_data.movie_details = ScrollableText::default(); + app.data.radarr_data.reset_movie_info_tab(); } _ => (), } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index a68fdf3..bc685fd 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use derivative::Derivative; -use indoc::{formatdoc, indoc}; +use indoc::formatdoc; use log::{debug, error}; use reqwest::RequestBuilder; use serde::de::DeserializeOwned; @@ -16,24 +16,26 @@ use crate::utils::{convert_runtime, convert_to_gb}; #[derive(Debug, Eq, PartialEq)] pub enum RadarrEvent { - HealthCheck, GetDownloads, - GetOverview, - GetStatus, GetMovies, GetMovieDetails, + GetMovieHistory, + GetOverview, GetQualityProfiles, + GetStatus, + HealthCheck, } impl RadarrEvent { const fn resource(self) -> &'static str { match self { - RadarrEvent::HealthCheck => "/health", - RadarrEvent::GetOverview => "/diskspace", - RadarrEvent::GetStatus => "/system/status", - RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails => "/movie", RadarrEvent::GetDownloads => "/queue", + RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails => "/movie", + RadarrEvent::GetMovieHistory => "/history/movie", + RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetQualityProfiles => "/qualityprofile", + RadarrEvent::GetStatus => "/system/status", + RadarrEvent::HealthCheck => "/health", } } } @@ -46,9 +48,7 @@ impl From for NetworkEvent { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -struct DiskSpace { - pub path: String, - pub label: String, +pub struct DiskSpace { pub free_space: Number, pub total_space: Number, } @@ -67,6 +67,7 @@ pub struct Movie { #[derivative(Default(value = "Number::from(0)"))] pub id: Number, pub title: String, + pub original_language: Language, #[derivative(Default(value = "Number::from(0)"))] pub size_on_disk: Number, pub status: String, @@ -82,6 +83,7 @@ pub struct Movie { pub runtime: Number, #[derivative(Default(value = "Number::from(0)"))] pub quality_profile_id: Number, + pub certification: Option, pub ratings: RatingsList, } @@ -133,6 +135,31 @@ struct QualityProfile { pub name: String, } +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MovieHistoryItem { + pub source_title: String, + pub quality: QualityHistory, + pub languages: Vec, + pub date: DateTime, + pub event_type: String, +} + +#[derive(Deserialize, Default, Debug, Clone)] +pub struct Language { + pub name: String, +} + +#[derive(Deserialize, Default, Debug, Clone)] +pub struct QualityHistory { + pub quality: Quality, +} + +#[derive(Deserialize, Default, Debug, Clone)] +pub struct Quality { + pub name: String, +} + impl<'a> Network<'a> { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { match radarr_event { @@ -153,6 +180,11 @@ impl<'a> Network<'a> { .get_movie_details(RadarrEvent::GetMovieDetails.resource()) .await } + RadarrEvent::GetMovieHistory => { + self + .get_movie_history(RadarrEvent::GetMovieHistory.resource()) + .await + } RadarrEvent::GetDownloads => { self .get_downloads(RadarrEvent::GetDownloads.resource()) @@ -175,14 +207,7 @@ impl<'a> Network<'a> { async fn get_diskspace(&self, resource: &str) { self .handle_get_request::>(resource, |disk_space_vec, mut app| { - let DiskSpace { - free_space, - total_space, - .. - } = &disk_space_vec[0]; - - app.data.radarr_data.free_space = free_space.as_u64().unwrap(); - app.data.radarr_data.total_space = total_space.as_u64().unwrap(); + app.data.radarr_data.disk_space_vec = disk_space_vec; }) .await; } @@ -199,87 +224,75 @@ impl<'a> Network<'a> { async fn get_movies(&self, resource: &str) { self .handle_get_request::>(resource, |movie_vec, mut app| { - app.data.radarr_data.movies.set_items(movie_vec); + app.data.radarr_data.movies.set_items(movie_vec) }) .await; } async fn get_movie_details(&self, resource: &str) { - let movie_id = self - .app - .lock() - .await - .data - .radarr_data - .movies - .current_selection() - .id - .clone() - .as_u64() - .unwrap(); - let mut url = resource.to_owned(); - url.push('/'); - url.push_str(movie_id.to_string().as_str()); + let movie_id = self.extract_movie_id().await; self - .handle_get_request::(url.as_str(), |movie_response, mut app| { - let Movie { - id, - title, - year, - overview, - path, - studio, - has_file, - quality_profile_id, - size_on_disk, - genres, - runtime, - ratings, - .. - } = movie_response; - let (hours, minutes) = convert_runtime(runtime.as_u64().unwrap()); - let size = convert_to_gb(size_on_disk.as_u64().unwrap()); - let quality_profile = app - .data - .radarr_data - .quality_profile_map - .get(&quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(); - let imdb_rating = if let Some(rating) = ratings.imdb { - if let Some(value) = rating.value.as_f64() { - format!("{:.1}", value) + .handle_get_request::( + format!("{}/{}", resource, movie_id).as_str(), + |movie_response, mut app| { + let Movie { + id, + title, + year, + overview, + path, + studio, + has_file, + quality_profile_id, + size_on_disk, + genres, + runtime, + ratings, + .. + } = movie_response; + let (hours, minutes) = convert_runtime(runtime.as_u64().unwrap()); + let size = convert_to_gb(size_on_disk.as_u64().unwrap()); + let quality_profile = app + .data + .radarr_data + .quality_profile_map + .get(&quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(); + let imdb_rating = if let Some(rating) = ratings.imdb { + if let Some(value) = rating.value.as_f64() { + format!("{:.1}", value) + } else { + "".to_owned() + } } else { "".to_owned() - } - } else { - "".to_owned() - }; + }; - let tmdb_rating = if let Some(rating) = ratings.tmdb { - if let Some(value) = rating.value.as_f64() { - format!("{}%", value * 10f64) + let tmdb_rating = if let Some(rating) = ratings.tmdb { + if let Some(value) = rating.value.as_f64() { + format!("{}%", value * 10f64) + } else { + "".to_owned() + } } else { "".to_owned() - } - } else { - "".to_owned() - }; + }; - let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { - if let Some(value) = rating.value.as_u64() { - format!("{}%", value) + let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { + if let Some(value) = rating.value.as_u64() { + format!("{}%", value) + } else { + "".to_owned() + } } else { "".to_owned() - } - } else { - "".to_owned() - }; + }; - let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); + let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); - app.data.radarr_data.movie_details = ScrollableText::with_string(formatdoc!( - "Title: {} + app.data.radarr_data.movie_details = ScrollableText::with_string(formatdoc!( + "Title: {} Year: {} Runtime: {}h {}m Status: {} @@ -292,22 +305,41 @@ impl<'a> Network<'a> { Path: {} Studio: {} Genres: {}", - title, - year, - hours, - minutes, - status, - overview, - tmdb_rating, - imdb_rating, - rotten_tomatoes_rating, - quality_profile, - size, - path, - studio, - genres.join(", ") - )) - }) + title, + year, + hours, + minutes, + status, + overview, + tmdb_rating, + imdb_rating, + rotten_tomatoes_rating, + quality_profile, + size, + path, + studio, + genres.join(", ") + )) + }, + ) + .await; + } + + async fn get_movie_history(&self, resource: &str) { + let movie_id = self.extract_movie_id().await; + self + .handle_get_request::>( + format!("{}?movieId={}", resource.to_owned(), movie_id).as_str(), + |movie_history_vec, mut app| { + let mut reversed_movie_history_vec = movie_history_vec.to_vec(); + reversed_movie_history_vec.reverse(); + app + .data + .radarr_data + .movie_history + .set_items(reversed_movie_history_vec) + }, + ) .await; } @@ -327,8 +359,8 @@ impl<'a> Network<'a> { self .handle_get_request::>(resource, |quality_profiles, mut app| { app.data.radarr_data.quality_profile_map = quality_profiles - .into_iter() - .map(|profile| (profile.id.as_u64().unwrap(), profile.name)) + .iter() + .map(|profile| (profile.id.as_u64().unwrap(), profile.name.clone())) .collect(); }) .await; @@ -372,4 +404,19 @@ impl<'a> Network<'a> { Err(e) => error!("Failed to fetch resource. {:?}", e), } } + + async fn extract_movie_id(&self) -> u64 { + self + .app + .lock() + .await + .data + .radarr_data + .movies + .current_selection() + .id + .clone() + .as_u64() + .unwrap() + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 86fe546..e17bc77 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,17 +1,22 @@ use tui::backend::Backend; use tui::layout::{Constraint, Rect}; -use tui::text::Text; -use tui::widgets::{Block, Borders, Clear, Paragraph}; +use tui::text::{Span, Spans, Text}; +use tui::widgets::Block; +use tui::widgets::Clear; +use tui::widgets::Paragraph; +use tui::widgets::Row; +use tui::widgets::Table; +use tui::widgets::Tabs; use tui::Frame; -use crate::app::radarr::ActiveRadarrBlock; +use crate::app::models::{StatefulTable, TabState}; use crate::app::{App, Route}; use crate::logos::{ BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO, }; use crate::ui::utils::{ - centered_rect, horizontal_chunks, horizontal_chunks_with_margin, style_system_function, - style_warning, vertical_chunks, vertical_chunks_with_margin, + centered_rect, layout_block_top_border, style_default_bold, style_highlight, style_secondary, + style_system_function, title_block, vertical_chunks_with_margin, }; mod radarr_ui; @@ -27,7 +32,7 @@ pub fn ui(f: &mut Frame, app: &mut App) { ); draw_context_row(f, app, main_chunks[0]); - match app.get_current_route().clone() { + match app.get_current_route() { Route::Radarr(_) => radarr_ui::draw_radarr_ui(f, app, main_chunks[1]), } } @@ -37,7 +42,7 @@ pub fn draw_popup_over( app: &mut App, area: Rect, background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), - popup_fn: fn(&mut Frame<'_, B>, &App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &mut App, Rect), percent_x: u16, percent_y: u16, ) { @@ -53,7 +58,7 @@ pub fn draw_small_popup_over( app: &mut App, area: Rect, background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), - popup_fn: fn(&mut Frame<'_, B>, &App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &mut App, Rect), ) { draw_popup_over(f, app, area, background_fn, popup_fn, 40, 40); } @@ -63,7 +68,7 @@ pub fn draw_medium_popup_over( app: &mut App, area: Rect, background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), - popup_fn: fn(&mut Frame<'_, B>, &App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &mut App, Rect), ) { draw_popup_over(f, app, area, background_fn, popup_fn, 60, 60); } @@ -73,17 +78,85 @@ pub fn draw_large_popup_over( app: &mut App, area: Rect, background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), - popup_fn: fn(&mut Frame<'_, B>, &App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &mut App, Rect), ) { draw_popup_over(f, app, area, background_fn, popup_fn, 75, 75); } fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { - match app.get_current_route().clone() { + match app.get_current_route() { Route::Radarr(_) => radarr_ui::draw_radarr_context_row(f, app, area), } } +fn draw_tabs<'a, B: Backend>( + f: &mut Frame<'_, B>, + area: Rect, + title: &str, + tab_state: &TabState, +) -> (Rect, Block<'a>) { + let chunks = + vertical_chunks_with_margin(vec![Constraint::Length(2), Constraint::Min(0)], area, 1); + let block = title_block(title); + + let titles = tab_state + .tabs + .iter() + .map(|tab_route| Spans::from(Span::styled(&tab_route.title, style_default_bold()))) + .collect(); + let tabs = Tabs::new(titles) + .block(block) + .highlight_style(style_secondary()) + .select(tab_state.index); + + f.render_widget(tabs, area); + + (chunks[1], layout_block_top_border()) +} + +pub struct TableProps<'a, T> { + pub content: &'a mut StatefulTable, + pub table_headers: Vec<&'a str>, + pub constraints: Vec, +} + +fn draw_table<'a, B, T, F>( + f: &mut Frame<'_, B>, + content_area: Rect, + block: Block, + table_props: TableProps<'a, T>, + row_mapper: F, + is_loading: bool, +) where + B: Backend, + F: Fn(&T) -> Row<'a>, +{ + let TableProps { + content, + table_headers, + constraints, + } = table_props; + + if !content.items.is_empty() { + let rows = content.items.iter().map(row_mapper); + + let headers = Row::new(table_headers) + .style(style_default_bold()) + .bottom_margin(0); + + let table = Table::new(rows) + .header(headers) + .block(block) + .highlight_style(style_highlight()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .widths(&constraints); + + f.render_stateful_widget(table, content_area, &mut content.state); + } else { + loading(f, block, content_area, is_loading); + } +} + pub fn loading(f: &mut Frame<'_, B>, block: Block<'_>, area: Rect, is_loading: bool) { if is_loading { let text = "\n\n Loading ...\n\n".to_owned(); diff --git a/src/ui/radarr_ui.rs b/src/ui/radarr_ui.rs index 8d195e9..706aa3a 100644 --- a/src/ui/radarr_ui.rs +++ b/src/ui/radarr_ui.rs @@ -2,50 +2,35 @@ use std::iter; use std::ops::Sub; use chrono::{Duration, Utc}; +use log::debug; use tui::backend::Backend; use tui::layout::{Alignment, Constraint, Rect}; use tui::style::Style; -use tui::text::{Span, Spans, Text}; -use tui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap}; +use tui::text::Text; +use tui::widgets::{Block, Cell, Paragraph, Row, Wrap}; use tui::Frame; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; use crate::app::{App, Route}; use crate::logos::RADARR_LOGO; -use crate::network::radarr_network::{DownloadRecord, Movie}; +use crate::network::radarr_network::{DiskSpace, DownloadRecord, Movie, MovieHistoryItem}; use crate::ui::utils::{ - horizontal_chunks_with_margin, line_gague, style_default, style_failure, style_highlight, - style_secondary, style_success, style_warning, title_block, vertical_chunks_with_margin, + horizontal_chunks_with_margin, layout_block_top_border, line_gague_with_label, + line_gague_with_title, style_bold, style_failure, style_success, style_warning, title_block, + vertical_chunks_with_margin, }; -use crate::ui::{draw_small_popup_over, loading, HIGHLIGHT_SYMBOL}; +use crate::ui::{draw_large_popup_over, draw_table, draw_tabs, loading, TableProps}; use crate::utils::{convert_runtime, convert_to_gb}; pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let chunks = - vertical_chunks_with_margin(vec![Constraint::Length(2), Constraint::Min(0)], area, 1); - let block = title_block(" Movies"); - - let titles = app - .data - .radarr_data - .main_tabs - .tabs - .iter() - .map(|tab_route| Spans::from(Span::styled(&tab_route.title, style_default()))) - .collect(); - let tabs = Tabs::new(titles) - .block(block) - .highlight_style(style_secondary()) - .select(app.data.radarr_data.main_tabs.index); - - f.render_widget(tabs, area); + let (content_rect, _) = draw_tabs(f, area, " Movies ", &app.data.radarr_data.main_tabs); if let Route::Radarr(active_radarr_block) = app.get_current_route() { match active_radarr_block { - ActiveRadarrBlock::Movies => draw_radarr_library(f, app, chunks[1]), - ActiveRadarrBlock::Downloads => draw_downloads(f, app, chunks[1]), - ActiveRadarrBlock::MovieDetails => { - draw_small_popup_over(f, app, chunks[1], draw_radarr_library, draw_movie_details) + ActiveRadarrBlock::Movies => draw_library(f, app, content_rect), + ActiveRadarrBlock::Downloads => draw_downloads(f, app, content_rect), + ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::MovieHistory => { + draw_large_popup_over(f, app, content_rect, draw_library, draw_movie_info) } _ => (), } @@ -54,70 +39,67 @@ pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, ar pub(super) fn draw_radarr_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { let chunks = horizontal_chunks_with_margin( - vec![ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ], + vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], area, 1, ); draw_stats_context(f, app, chunks[0]); - f.render_widget(Block::default().borders(Borders::ALL), chunks[1]); - draw_downloads_context(f, app, chunks[2]); + draw_downloads_context(f, app, chunks[1]); } -fn draw_radarr_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let block = Block::default().borders(Borders::TOP); - let movies_vec = &app.data.radarr_data.movies.items; +fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let quality_profile_map = &app.data.radarr_data.quality_profile_map; + let downloads_vec = &app.data.radarr_data.downloads.items; - if !movies_vec.is_empty() { - let rows = movies_vec.iter().map(|movie| { + draw_table( + f, + area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.movies, + table_headers: vec![ + "Title", + "Year", + "Runtime", + "Rating", + "Language", + "Size", + "Quality Profile", + ], + constraints: vec![ + Constraint::Percentage(25), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(12), + ], + }, + |movie| { let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); let file_size: f64 = convert_to_gb(movie.size_on_disk.as_u64().unwrap()); + let certification = movie.certification.clone().unwrap_or_else(|| "".to_owned()); Row::new(vec![ Cell::from(movie.title.to_owned()), Cell::from(movie.year.to_string()), Cell::from(format!("{}h {}m", hours, minutes)), + Cell::from(certification), + Cell::from(movie.original_language.name.to_owned()), Cell::from(format!("{:.2} GB", file_size)), Cell::from( - app - .data - .radarr_data - .quality_profile_map + quality_profile_map .get(&movie.quality_profile_id.as_u64().unwrap()) .unwrap() .to_owned(), ), ]) - .style(determine_row_style(app, movie)) - }); - - let header_row = Row::new(vec!["Title", "Year", "Runtime", "Size", "Quality Profile"]) - .style(style_default()) - .bottom_margin(0); - - let constraints = vec![ - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), - ]; - - let table = Table::new(rows) - .header(header_row) - .block(block) - .highlight_style(style_highlight()) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .widths(&constraints); - - f.render_stateful_widget(table, area, &mut app.data.radarr_data.movies.state); - } else { - loading(f, block, area, app.is_loading); - } + .style(determine_row_style(downloads_vec, movie)) + }, + app.is_loading, + ); } fn draw_downloads_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { @@ -141,7 +123,7 @@ fn draw_downloads_context(f: &mut Frame<'_, B>, app: &App, area: Rec .. } = &downloads_vec[i]; let percent = 1f64 - (sizeleft.as_f64().unwrap() / size.as_f64().unwrap()); - let download_gague = line_gague(title, percent); + let download_gague = line_gague_with_title(title, percent); f.render_widget(download_gague, chunks[i]); } @@ -151,11 +133,30 @@ fn draw_downloads_context(f: &mut Frame<'_, B>, app: &App, area: Rec } fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let block = Block::default().borders(Borders::TOP); - let downloads_vec = &app.data.radarr_data.downloads.items; - - if !downloads_vec.is_empty() { - let rows = downloads_vec.iter().map(|download_record| { + draw_table( + f, + area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.downloads, + table_headers: vec![ + "Title", + "Percent Complete", + "Size", + "Output Path", + "Indexer", + "Download Client", + ], + constraints: vec![ + Constraint::Percentage(30), + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(18), + Constraint::Percentage(17), + Constraint::Percentage(13), + ], + }, + |download_record| { let DownloadRecord { title, size, @@ -177,43 +178,32 @@ fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { Cell::from(download_client.to_owned()), ]) .style(style_success()) - }); + }, + app.is_loading, + ); +} - let header_row = Row::new(vec![ - "Title", - "Percent Complete", - "Size", - "Output Path", - "Indexer", - "Download Client", - ]) - .style(style_default()) - .bottom_margin(0); +fn draw_movie_info(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let (content_area, block) = + draw_tabs(f, area, "Movie Info", &app.data.radarr_data.movie_info_tabs); - let constraints = vec![ - Constraint::Percentage(30), - Constraint::Percentage(11), - Constraint::Percentage(11), - Constraint::Percentage(18), - Constraint::Percentage(17), - Constraint::Percentage(13), - ]; - - let table = Table::new(rows) - .header(header_row) - .block(block) - .highlight_style(style_highlight()) - .highlight_symbol(HIGHLIGHT_SYMBOL) - .widths(&constraints); - - f.render_stateful_widget(table, area, &mut app.data.radarr_data.downloads.state); - } else { - loading(f, block, area, app.is_loading); + if let Route::Radarr(active_radarr_block) = + app.data.radarr_data.movie_info_tabs.get_active_route() + { + match active_radarr_block { + ActiveRadarrBlock::MovieDetails => draw_movie_details(f, app, content_area, block), + ActiveRadarrBlock::MovieHistory => draw_movie_history(f, app, content_area, block), + _ => (), + } } } -fn draw_movie_details(f: &mut Frame<'_, B>, app: &App, area: Rect) { - let block = title_block("Movie Details"); +fn draw_movie_details( + f: &mut Frame<'_, B>, + app: &App, + content_area: Rect, + block: Block, +) { let movie_details = app.data.radarr_data.movie_details.get_text(); if !movie_details.is_empty() { @@ -235,48 +225,94 @@ fn draw_movie_details(f: &mut Frame<'_, B>, app: &App, area: Rect) { .wrap(Wrap { trim: false }) .scroll((app.data.radarr_data.movie_details.offset, 0)); - f.render_widget(paragraph, area); + f.render_widget(paragraph, content_area); } else { - loading(f, block, area, app.is_loading); + loading(f, block, content_area, app.is_loading); } } +fn draw_movie_history( + f: &mut Frame<'_, B>, + app: &mut App, + content_area: Rect, + block: Block, +) { + draw_table( + f, + content_area, + block, + TableProps { + content: &mut app.data.radarr_data.movie_history, + table_headers: vec!["Source Title", "Event Type", "Languages", "Quality", "Date"], + constraints: vec![ + Constraint::Percentage(34), + Constraint::Percentage(17), + Constraint::Percentage(14), + Constraint::Percentage(14), + Constraint::Percentage(21), + ], + }, + |movie_history_item| { + let MovieHistoryItem { + source_title, + quality, + languages, + date, + event_type, + } = movie_history_item; + + Row::new(vec![ + Cell::from(source_title.to_owned()), + Cell::from(event_type.to_owned()), + Cell::from( + languages + .iter() + .map(|language| language.name.to_owned()) + .collect::>() + .join(","), + ), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .style(style_success()) + }, + app.is_loading, + ); +} + fn draw_stats_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { let block = title_block("Stats"); if !app.data.radarr_data.version.is_empty() { + f.render_widget(block, area); let RadarrData { - free_space, - total_space, + disk_space_vec, start_time, .. - } = app.data.radarr_data; - let ratio = if total_space == 0 { - 0f64 - } else { - 1f64 - (free_space as f64 / total_space as f64) - }; + } = &app.data.radarr_data; - f.render_widget(block, area); + let mut constraints = vec![ + Constraint::Percentage(60), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; - let chunks = vertical_chunks_with_margin( - vec![ - Constraint::Percentage(60), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2), - ], - area, - 1, + constraints.append( + &mut iter::repeat(Constraint::Min(2)) + .take(disk_space_vec.len()) + .collect(), ); + let chunks = vertical_chunks_with_margin(constraints, area, 1); + let version_paragraph = Paragraph::new(Text::from(format!( "Radarr Version: {}", app.data.radarr_data.version ))) .block(Block::default()); - let uptime = Utc::now().sub(start_time); + let uptime = Utc::now().sub(start_time.to_owned()); let days = uptime.num_days(); let day_difference = uptime.sub(Duration::days(days)); let hours = day_difference.num_hours(); @@ -296,23 +332,39 @@ fn draw_stats_context(f: &mut Frame<'_, B>, app: &App, area: Rect) { ))) .block(Block::default()); - let space_gauge = line_gague("Storage:", ratio); let logo = Paragraph::new(Text::from(RADARR_LOGO)) .block(Block::default()) .alignment(Alignment::Center); + let storage = + Paragraph::new(Text::from("Storage:")).block(Block::default().style(style_bold())); f.render_widget(logo, chunks[0]); f.render_widget(version_paragraph, chunks[1]); f.render_widget(uptime_paragraph, chunks[2]); - f.render_widget(space_gauge, chunks[3]); + f.render_widget(storage, chunks[3]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if total_space.as_u64().unwrap() == 0 { + 0f64 + } else { + 1f64 - (free_space.as_u64().unwrap() as f64 / total_space.as_u64().unwrap() as f64) + }; + + let space_gauge = line_gague_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, chunks[i + 4]); + } } else { loading(f, block, area, app.is_loading); } } -fn determine_row_style(app: &App, movie: &Movie) -> Style { - let downloads_vec = &app.data.radarr_data.downloads.items; - +fn determine_row_style(downloads_vec: &[DownloadRecord], movie: &Movie) -> Style { if !movie.has_file { if let Some(download) = downloads_vec .iter() diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 9e4bde6..f965ec2 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -50,12 +50,20 @@ pub fn vertical_chunks_with_margin( .split(size) } -pub fn layout_block(title_span: Span<'_>) -> Block<'_> { - Block::default().borders(Borders::ALL).title(title_span) +pub fn layout_block<'a>() -> Block<'a> { + Block::default().borders(Borders::ALL) } -pub fn layout_block_top_border(title_span: Span<'_>) -> Block<'_> { - Block::default().borders(Borders::TOP).title(title_span) +pub fn layout_block_with_title(title_span: Span<'_>) -> Block<'_> { + layout_block().title(title_span) +} + +pub fn layout_block_top_border_with_title(title_span: Span<'_>) -> Block<'_> { + layout_block_top_border().title(title_span) +} + +pub fn layout_block_top_border<'a>() -> Block<'a> { + Block::default().borders(Borders::TOP) } pub fn style_bold() -> Style { @@ -70,6 +78,10 @@ pub fn style_default() -> Style { Style::default().fg(Color::White) } +pub fn style_default_bold() -> Style { + style_default().add_modifier(Modifier::BOLD) +} + pub fn style_primary() -> Style { Style::default().fg(Color::Cyan) } @@ -99,7 +111,7 @@ pub fn title_style(title: &str) -> Span<'_> { } pub fn title_block(title: &str) -> Block<'_> { - layout_block(title_style(title)) + layout_block_with_title(title_style(title)) } pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { @@ -128,7 +140,7 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .split(popup_layout[1])[1] } -pub fn line_gague(title: &str, ratio: f64) -> LineGauge { +pub fn line_gague_with_title(title: &str, ratio: f64) -> LineGauge { LineGauge::default() .block(Block::default().title(title)) .gauge_style(Style::default().fg(Color::Cyan)) @@ -136,3 +148,12 @@ pub fn line_gague(title: &str, ratio: f64) -> LineGauge { .ratio(ratio) .label(Spans::from(format!("{:.0}%", ratio * 100.0))) } + +pub fn line_gague_with_label(title: &str, ratio: f64) -> LineGauge { + LineGauge::default() + .block(Block::default()) + .gauge_style(Style::default().fg(Color::Cyan)) + .line_set(symbols::line::THICK) + .ratio(ratio) + .label(Spans::from(format!("{}: {:.0}%", title, ratio * 100.0))) +}