diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 2a09062..b36a306 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -17,29 +17,29 @@ generate_keybindings! { } pub struct KeyBinding { - pub key: Key, - pub desc: &'static str + pub key: Key, + pub desc: &'static str, } pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { - quit: KeyBinding { - key: Key::Char('q'), - desc: "Quit", - }, - up: KeyBinding { - key: Key::Up, - desc: "Scroll up" - }, - down: KeyBinding { - key: Key::Down, - desc: "Scroll down" - }, - submit: KeyBinding { - key: Key::Enter, - desc: "Select" - }, - esc: KeyBinding { - key: Key::Esc, - desc: "Exit menu" - } -}; \ No newline at end of file + quit: KeyBinding { + key: Key::Char('q'), + desc: "Quit", + }, + up: KeyBinding { + key: Key::Up, + desc: "Scroll up", + }, + down: KeyBinding { + key: Key::Down, + desc: "Scroll down", + }, + submit: KeyBinding { + key: Key::Enter, + desc: "Select", + }, + esc: KeyBinding { + key: Key::Esc, + desc: "Exit current menu", + }, +}; diff --git a/src/app/mod.rs b/src/app/mod.rs index 01de144..aa85e90 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,21 +1,27 @@ -use log::error; +use log::{debug, error}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::Sender; -use tui::widgets::TableState; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; -use crate::network::NetworkEvent; use crate::network::radarr_network::RadarrEvent; +use crate::network::NetworkEvent; pub(crate) mod key_binding; +pub mod models; pub mod radarr; -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Route { Radarr(ActiveRadarrBlock), } +impl From for Route { + fn from(active_radarr_block: ActiveRadarrBlock) -> Route { + Route::Radarr(active_radarr_block) + } +} + const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies); pub struct App { @@ -26,6 +32,7 @@ pub struct App { pub tick_until_poll: u64, pub tick_count: u64, pub is_routing: bool, + pub is_loading: bool, pub config: AppConfig, pub data: Data, } @@ -41,8 +48,10 @@ impl App { } pub async fn dispatch(&mut self, action: NetworkEvent) { + self.is_loading = true; if let Some(network_tx) = &self.network_tx { if let Err(e) = network_tx.send(action).await { + self.is_loading = false; error!("Failed to send event. {:?}", e); } } @@ -70,7 +79,6 @@ impl App { self.dispatch(RadarrEvent::GetOverview.into()).await; self.dispatch(RadarrEvent::GetStatus.into()).await; - self.dispatch_by_radarr_block(active_block).await; } } @@ -87,6 +95,7 @@ impl App { } pub fn pop_navigation_stack(&mut self) { + self.is_routing = true; if self.navigation_stack.len() > 1 { self.navigation_stack.pop(); } @@ -106,6 +115,7 @@ impl Default for App { title: "Managarr", tick_until_poll: 0, tick_count: 0, + is_loading: false, is_routing: false, config: AppConfig::default(), data: Data::default(), @@ -139,66 +149,3 @@ impl Default for RadarrConfig { } } } - -pub struct StatefulTable { - pub state: TableState, - pub items: Vec -} - -impl Default for StatefulTable { - fn default() -> StatefulTable { - StatefulTable { - state: TableState::default(), - items: Vec::new() - } - } -} - -impl StatefulTable { - pub fn set_items(&mut self, items: Vec) { - let items_len = items.len(); - self.items = items; - if !self.items.is_empty() { - let selected_row = self.state.selected().map_or(0, |i| { - if i > 0 && i < items_len { - i - } else if i >= items_len { - items_len - 1 - } else { - 0 - } - }); - self.state.select(Some(selected_row)); - } - } - - pub fn scroll_down(&mut self) { - let selected_row = match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0 - }; - - self.state.select(Some(selected_row)); - } - - pub fn scroll_up(&mut self) { - let selected_row = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => 0 - }; - - self.state.select(Some(selected_row)); - } -} diff --git a/src/app/models.rs b/src/app/models.rs new file mode 100644 index 0000000..559eb2f --- /dev/null +++ b/src/app/models.rs @@ -0,0 +1,107 @@ +use tui::widgets::TableState; + +pub trait Scrollable { + fn scroll_down(&mut self); + fn scroll_up(&mut self); +} + +pub struct StatefulTable { + pub state: TableState, + pub items: Vec, +} + +impl Default for StatefulTable { + fn default() -> StatefulTable { + StatefulTable { + state: TableState::default(), + items: Vec::new(), + } + } +} + +impl StatefulTable { + pub fn set_items(&mut self, items: Vec) { + let items_len = items.len(); + self.items = items; + if !self.items.is_empty() { + let selected_row = self.state.selected().map_or(0, |i| { + if i > 0 && i < items_len { + i + } else if i >= items_len { + items_len - 1 + } else { + 0 + } + }); + self.state.select(Some(selected_row)); + } + } + + pub fn current_selection(&self) -> &T { + &self.items[self.state.selected().unwrap_or(0)] + } +} + +impl Scrollable for StatefulTable { + fn scroll_down(&mut self) { + let selected_row = match self.state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } + + fn scroll_up(&mut self) { + let selected_row = match self.state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self.state.select(Some(selected_row)); + } +} + +#[derive(Default)] +pub struct ScrollableText { + pub items: Vec, + pub offset: u16, +} + +impl ScrollableText { + pub fn with_string(item: String) -> ScrollableText { + let items: Vec<&str> = item.split('\n').collect(); + let items: Vec = items.iter().map(|it| it.to_string()).collect(); + ScrollableText { items, offset: 0 } + } + + pub fn get_text(&self) -> String { + self.items.join("\n") + } +} + +impl Scrollable for ScrollableText { + fn scroll_down(&mut self) { + if self.offset < self.items.len() as u16 { + self.offset += 1; + } + } + + fn scroll_up(&mut self) { + if self.offset > 0 { + self.offset -= 1; + } + } +} diff --git a/src/app/radarr.rs b/src/app/radarr.rs index 5b3f447..8030fcd 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; -use crate::app::{App, StatefulTable}; +use crate::app::models::{ScrollableText, StatefulTable}; +use crate::app::App; use crate::network::radarr_network::{DownloadRecord, Movie, RadarrEvent}; #[derive(Default)] @@ -14,9 +15,10 @@ pub struct RadarrData { pub movies: StatefulTable, pub downloads: StatefulTable, pub quality_profile_map: HashMap, + pub movie_details: ScrollableText, } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ActiveRadarrBlock { AddMovie, Calendar, @@ -33,13 +35,15 @@ pub enum ActiveRadarrBlock { impl App { pub(super) async fn dispatch_by_radarr_block(&mut self, active_radarr_block: ActiveRadarrBlock) { + self.reset_tick_count(); match active_radarr_block { ActiveRadarrBlock::Downloads => self.dispatch(RadarrEvent::GetDownloads.into()).await, ActiveRadarrBlock::Movies => { self.dispatch(RadarrEvent::GetMovies.into()).await; self.dispatch(RadarrEvent::GetDownloads.into()).await; - }, - _ => () + } + ActiveRadarrBlock::MovieDetails => self.dispatch(RadarrEvent::GetMovieDetails.into()).await, + _ => (), } } } diff --git a/src/event/input_event.rs b/src/event/input_event.rs index ad6444e..2308977 100644 --- a/src/event/input_event.rs +++ b/src/event/input_event.rs @@ -31,8 +31,8 @@ impl Events { .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if event::poll(timeout).unwrap() { - if let CrosstermEvent::Key(key) = event::read().unwrap() { - let key = Key::from(key); + if let CrosstermEvent::Key(key_event) = event::read().unwrap() { + let key = Key::from(key_event); event_tx.send(InputEvent::KeyEvent(key)).unwrap(); } } diff --git a/src/event/key.rs b/src/event/key.rs index 8602f63..e0bb291 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -26,8 +26,7 @@ impl From for Key { fn from(key_event: KeyEvent) -> Self { match key_event { KeyEvent { - code: KeyCode::Up, - .. + code: KeyCode::Up, .. } => Key::Up, KeyEvent { code: KeyCode::Down, @@ -37,6 +36,9 @@ impl From for Key { code: KeyCode::Enter, .. } => Key::Enter, + KeyEvent { + code: KeyCode::Esc, .. + } => Key::Esc, KeyEvent { code: KeyCode::Char(c), .. diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index ae89321..873718e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,29 +1,13 @@ -use crate::app::App; -use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::{App, Route}; use crate::event::Key; +use crate::handlers::radarr_handler::handle_radarr_key_events; + +mod radarr_handler; pub async fn handle_key_events(key: Key, app: &mut App) { - match key { - _ if key == DEFAULT_KEYBINDINGS.up.key => handle_scroll_up(app).await, - _ if key == DEFAULT_KEYBINDINGS.down.key => handle_scroll_down(app).await, - _ if key == DEFAULT_KEYBINDINGS.submit.key => handle_submit(app).await, - _ if key == DEFAULT_KEYBINDINGS.esc.key => handle_esc(app).await, - _ => () + match *app.get_current_route() { + Route::Radarr(active_radarr_block) => { + handle_radarr_key_events(key, app, active_radarr_block).await } + } } - -async fn handle_scroll_up(app: &mut App) { - app.data.radarr_data.movies.scroll_up(); -} - -async fn handle_scroll_down(app: &mut App) { - app.data.radarr_data.movies.scroll_down(); -} - -async fn handle_submit(app: &mut App) { - todo!() -} - -async fn handle_esc(app: &mut App) { - todo!() -} \ No newline at end of file diff --git a/src/handlers/radarr_handler.rs b/src/handlers/radarr_handler.rs new file mode 100644 index 0000000..7d93bb2 --- /dev/null +++ b/src/handlers/radarr_handler.rs @@ -0,0 +1,48 @@ +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::app::models::Scrollable; +use crate::app::radarr::ActiveRadarrBlock; +use crate::{App, Key}; + +pub async fn handle_radarr_key_events( + key: Key, + app: &mut App, + active_radarr_block: ActiveRadarrBlock, +) { + match key { + _ if key == DEFAULT_KEYBINDINGS.up.key => handle_scroll_up(app, active_radarr_block).await, + _ if key == DEFAULT_KEYBINDINGS.down.key => handle_scroll_down(app, active_radarr_block).await, + _ if key == DEFAULT_KEYBINDINGS.submit.key => handle_submit(app, active_radarr_block).await, + _ if key == DEFAULT_KEYBINDINGS.esc.key => handle_esc(app, active_radarr_block).await, + _ => (), + } +} + +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(), + _ => (), + } +} + +async fn handle_scroll_down(app: &mut App, active_radarr_block: ActiveRadarrBlock) { + match active_radarr_block { + ActiveRadarrBlock::Movies => app.data.radarr_data.movies.scroll_down(), + ActiveRadarrBlock::MovieDetails => app.data.radarr_data.movie_details.scroll_down(), + _ => (), + } +} + +async fn handle_submit(app: &mut App, active_radarr_block: ActiveRadarrBlock) { + match active_radarr_block { + ActiveRadarrBlock::Movies => app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into()), + _ => (), + } +} + +async fn handle_esc(app: &mut App, active_radarr_block: ActiveRadarrBlock) { + match active_radarr_block { + ActiveRadarrBlock::MovieDetails => app.pop_navigation_stack(), + _ => (), + } +} diff --git a/src/main.rs b/src/main.rs index 18cc7f1..3ba1d41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,8 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use log::debug; -use tokio::sync::{mpsc, Mutex}; use tokio::sync::mpsc::Receiver; +use tokio::sync::{mpsc, Mutex}; use tui::backend::CrosstermBackend; use tui::Terminal; @@ -55,7 +55,10 @@ async fn start_networking(mut network_rx: Receiver, app: &Arc>) -> Result<()> { handlers::handle_key_events(key, &mut app).await; } + InputEvent::Tick => app.on_tick(is_first_render).await, } diff --git a/src/network/mod.rs b/src/network/mod.rs index a680ac7..563bf32 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -11,7 +11,7 @@ mod utils; #[derive(PartialEq, Eq, Debug)] pub enum NetworkEvent { - Radarr(RadarrEvent) + Radarr(RadarrEvent), } pub struct Network<'a> { @@ -27,7 +27,10 @@ impl<'a> Network<'a> { pub async fn handle_network_event(&self, network_event: NetworkEvent) { match network_event { - NetworkEvent::Radarr(radarr_event) => self.handle_radarr_event(radarr_event).await + NetworkEvent::Radarr(radarr_event) => self.handle_radarr_event(radarr_event).await, } + + let mut app = self.app.lock().await; + app.is_loading = false; } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index ed4f9cd..e94dab3 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -7,8 +7,11 @@ use serde::Deserialize; use serde_json::Number; use tokio::sync::MutexGuard; +use crate::app::models::ScrollableText; use crate::app::{App, RadarrConfig}; -use crate::network::{Network, NetworkEvent, utils}; +use crate::network::utils::get_movie_status; +use crate::network::{utils, Network, NetworkEvent}; +use crate::utils::{convert_runtime, convert_to_gb}; #[derive(Debug, Eq, PartialEq)] pub enum RadarrEvent { @@ -17,6 +20,7 @@ pub enum RadarrEvent { GetOverview, GetStatus, GetMovies, + GetMovieDetails, GetQualityProfiles, } @@ -26,9 +30,9 @@ impl RadarrEvent { RadarrEvent::HealthCheck => "/health", RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetStatus => "/system/status", - RadarrEvent::GetMovies => "/movie", + RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails => "/movie", RadarrEvent::GetDownloads => "/queue", - RadarrEvent::GetQualityProfiles => "/qualityprofile" + RadarrEvent::GetQualityProfiles => "/qualityprofile", } } } @@ -65,6 +69,10 @@ pub struct Movie { #[derivative(Default(value = "Number::from(0)"))] pub size_on_disk: Number, pub status: String, + pub overview: String, + pub path: String, + pub studio: String, + pub genres: Vec, #[derivative(Default(value = "Number::from(0)"))] pub year: Number, pub monitored: bool, @@ -73,13 +81,29 @@ pub struct Movie { pub runtime: Number, #[derivative(Default(value = "Number::from(0)"))] pub quality_profile_id: Number, + pub ratings: RatingsList, +} + +#[derive(Default, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RatingsList { + pub imdb: Option, + pub tmdb: Option, + pub rotten_tomatoes: Option, +} + +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +pub struct Rating { + #[derivative(Default(value = "Number::from(0)"))] + pub value: Number, } #[derive(Derivative, Deserialize, Debug)] #[derivative(Default)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { - pub records: Vec + pub records: Vec, } #[derive(Derivative, Deserialize, Debug)] @@ -96,7 +120,7 @@ pub struct DownloadRecord { pub sizeleft: Number, pub output_path: String, pub indexer: String, - pub download_client: String + pub download_client: String, } #[derive(Derivative, Deserialize, Debug)] @@ -105,22 +129,43 @@ pub struct DownloadRecord { struct QualityProfile { #[derivative(Default(value = "Number::from(0)"))] pub id: Number, - pub name: String + pub name: String, } impl<'a> Network<'a> { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { match radarr_event { - RadarrEvent::HealthCheck => self.get_healthcheck(RadarrEvent::HealthCheck.resource()).await, - RadarrEvent::GetOverview => self.get_diskspace(RadarrEvent::GetOverview.resource()).await, + RadarrEvent::HealthCheck => { + self + .get_healthcheck(RadarrEvent::HealthCheck.resource()) + .await + } + RadarrEvent::GetOverview => { + self + .get_diskspace(RadarrEvent::GetOverview.resource()) + .await + } RadarrEvent::GetStatus => self.get_status(RadarrEvent::GetStatus.resource()).await, RadarrEvent::GetMovies => self.get_movies(RadarrEvent::GetMovies.resource()).await, - RadarrEvent::GetDownloads => self.get_downloads(RadarrEvent::GetDownloads.resource()).await, - RadarrEvent::GetQualityProfiles => self.get_quality_profiles(RadarrEvent::GetQualityProfiles.resource()).await + RadarrEvent::GetMovieDetails => { + debug!("TEST received GetMovieDetails event"); + self + .get_movie_details(RadarrEvent::GetMovieDetails.resource()) + .await + } + RadarrEvent::GetDownloads => { + self + .get_downloads(RadarrEvent::GetDownloads.resource()) + .await + } + RadarrEvent::GetQualityProfiles => { + self + .get_quality_profiles(RadarrEvent::GetQualityProfiles.resource()) + .await + } } let mut app = self.app.lock().await; - app.reset_tick_count(); } async fn get_healthcheck(&self, resource: &str) { @@ -130,43 +175,155 @@ 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]; + 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(); - }).await; + app.data.radarr_data.free_space = free_space.as_u64().unwrap(); + app.data.radarr_data.total_space = total_space.as_u64().unwrap(); + }) + .await; } async fn get_status(&self, resource: &str) { - self.handle_get_request::(resource, | system_status, mut app | { - app.data.radarr_data.version = system_status.version; - app.data.radarr_data.start_time = system_status.start_time; - }).await; + self + .handle_get_request::(resource, |system_status, mut app| { + app.data.radarr_data.version = system_status.version; + app.data.radarr_data.start_time = system_status.start_time; + }) + .await; } 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); - }).await; + self + .handle_get_request::>(resource, |movie_vec, mut app| { + app.data.radarr_data.movies.set_items(movie_vec); + }) + .await; + } + + async fn get_movie_details(&self, resource: &str) { + debug!("TEST handling get_movie_details"); + 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()); + debug!("TEST sending request{}", url.as_str()); + 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 { + rating.value.as_f64().unwrap() + } else { + 0f64 + }; + + let tmdb_rating = if let Some(rating) = ratings.tmdb { + rating.value.as_u64().unwrap() + } else { + 0u64 + }; + + let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { + rating.value.as_u64().unwrap() + } else { + 0u64 + }; + + let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); + + app.data.radarr_data.movie_details = ScrollableText::with_string(format!( + "Title: {}\n + Year: {}\n + Runtime: {}h {}m\n + Status: {}\n + Description: {}\n + TMDB: {}%\n + IMDB: {:.1}\n + Rotten Tomatoes: {}%\n + Quality Profile: {}\n + Size: {:.2} GB\n + Path: {}\n + Studio: {}\n + Genres: {}", + title, + year, + hours, + minutes, + status, + overview, + tmdb_rating, + imdb_rating, + rotten_tomatoes_rating, + quality_profile, + size, + path, + studio, + genres.join(", ") + )) + }) + .await; } async fn get_downloads(&self, resource: &str) { - self.handle_get_request::(resource, |queue_response, mut app | { - app.data.radarr_data.downloads.set_items(queue_response.records); - }).await + self + .handle_get_request::(resource, |queue_response, mut app| { + app + .data + .radarr_data + .downloads + .set_items(queue_response.records); + }) + .await } async fn get_quality_profiles(&self, resource: &str) { - 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)) + 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)) .collect(); - }).await; + }) + .await; } async fn call_radarr_api(&self, resource: &str) -> RequestBuilder { @@ -189,23 +346,22 @@ impl<'a> Network<'a> { .header("X-Api-Key", api_token) } - async fn handle_get_request(&self, resource: &str, mut app_update_fn: impl FnMut(T, MutexGuard)) - where - T: DeserializeOwned { - match self.call_radarr_api(resource) - .await - .send() - .await { - Ok(response) => { - match utils::parse_response::(response).await { - Ok(value) => { - let app = self.app.lock().await; - app_update_fn(value, app); - } - Err(e) => error!("Failed to parse response! {:?}", e) + async fn handle_get_request( + &self, + resource: &str, + mut app_update_fn: impl FnMut(T, MutexGuard), + ) where + T: DeserializeOwned, + { + match self.call_radarr_api(resource).await.send().await { + Ok(response) => match utils::parse_response::(response).await { + Ok(value) => { + let app = self.app.lock().await; + app_update_fn(value, app); } - } - Err(e) => error!("Failed to fetch resource. {:?}", e) + Err(e) => error!("Failed to parse response! {:?}", e), + }, + Err(e) => error!("Failed to fetch resource. {:?}", e), } } } diff --git a/src/network/utils.rs b/src/network/utils.rs index 7ed33d1..1b83d21 100644 --- a/src/network/utils.rs +++ b/src/network/utils.rs @@ -1,6 +1,30 @@ use reqwest::Response; use serde::de::DeserializeOwned; +use serde_json::Number; + +use crate::network::radarr_network::DownloadRecord; pub async fn parse_response(response: Response) -> Result { - response.json::().await -} \ No newline at end of file + response.json::().await +} + +pub fn get_movie_status( + has_file: bool, + downloads_vec: &Vec, + movie_id: Number, +) -> String { + if !has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.movie_id == movie_id) + { + if download.status == "downloading" { + return "Downloading".to_owned(); + } + + return "Missing".to_owned(); + } + } + + "Downloaded".to_owned() +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index da3c1ae..b25c74b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,18 +1,21 @@ use tui::backend::Backend; -use tui::Frame; use tui::layout::{Constraint, Rect}; -use tui::widgets::{Block, Borders}; +use tui::text::Text; +use tui::widgets::{Block, Borders, Clear, Paragraph}; +use tui::Frame; -use crate::app::App; +use crate::app::radarr::ActiveRadarrBlock; +use crate::app::{App, Route}; use crate::logos::{ BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO, }; use crate::ui::utils::{ - horizontal_chunks, horizontal_chunks_with_margin, vertical_chunks, vertical_chunks_with_margin, + centered_rect, horizontal_chunks, horizontal_chunks_with_margin, style_secondary, + vertical_chunks, vertical_chunks_with_margin, }; -mod utils; mod radarr_ui; +mod utils; static HIGHLIGHT_SYMBOL: &str = "=> "; @@ -20,11 +23,37 @@ pub fn ui(f: &mut Frame, app: &mut App) { let main_chunks = vertical_chunks_with_margin( vec![Constraint::Length(20), Constraint::Length(0)], f.size(), - 1 + 1, ); draw_context_row(f, app, main_chunks[0]); - radarr_ui::draw_radarr_ui(f, app, main_chunks[1]); + match *app.get_current_route() { + Route::Radarr(active_radarr_block) => match active_radarr_block { + ActiveRadarrBlock::Movies => radarr_ui::draw_radarr_ui(f, app, main_chunks[1]), + ActiveRadarrBlock::MovieDetails => draw_popup_over( + f, + app, + main_chunks[1], + radarr_ui::draw_radarr_ui, + radarr_ui::draw_movie_details, + ), + _ => (), + }, + } +} + +pub fn draw_popup_over( + f: &mut Frame<'_, B>, + app: &mut App, + area: Rect, + background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &App, Rect), +) { + background_fn(f, app, area); + + let popup_area = centered_rect(75, 75, f.size()); + f.render_widget(Clear, popup_area); + popup_fn(f, app, popup_area); } fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { @@ -46,3 +75,16 @@ fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { radarr_ui::draw_logo(f, chunks[4]); } + +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(); + let mut text = Text::from(text); + text.patch_style(style_secondary()); + + let paragraph = Paragraph::new(text).style(style_secondary()).block(block); + f.render_widget(paragraph, area); + } else { + f.render_widget(block, area) + } +} diff --git a/src/ui/radarr_ui.rs b/src/ui/radarr_ui.rs index 770f2b7..2b5d45f 100644 --- a/src/ui/radarr_ui.rs +++ b/src/ui/radarr_ui.rs @@ -1,87 +1,136 @@ use std::ops::Sub; use chrono::{Duration, Utc}; -use tui::{Frame, symbols}; use tui::backend::Backend; use tui::layout::{Alignment, Constraint, Rect}; use tui::style::{Color, Modifier, Style}; use tui::text::{Span, Spans, Text}; -use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table}; +use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table, Wrap}; +use tui::{symbols, Frame}; -use crate::app::App; use crate::app::radarr::RadarrData; +use crate::app::App; use crate::logos::RADARR_LOGO; use crate::network::radarr_network::Movie; -use crate::ui::HIGHLIGHT_SYMBOL; -use crate::ui::utils::{style_default, style_highlight, style_primary, style_secondary, style_tertiary, title_block, vertical_chunks, vertical_chunks_with_margin}; +use crate::ui::utils::{ + style_default, style_highlight, style_primary, style_secondary, style_tertiary, title_block, + vertical_chunks, vertical_chunks_with_margin, +}; +use crate::ui::{loading, HIGHLIGHT_SYMBOL}; +use crate::utils::{convert_runtime, convert_to_gb}; pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let block = Block::default().borders(Borders::ALL).title(Spans::from(vec![ - Span::styled("Movies", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) - ])); + let block = Block::default() + .borders(Borders::ALL) + .title(Spans::from(vec![Span::styled( + "Movies", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); - let rows = app.data.radarr_data.movies.items - .iter() - .map(|movie| { - let runtime = movie.runtime.as_u64().unwrap(); - let hours = runtime / 60; - let minutes = runtime % 60; - let file_size: f64 = movie.size_on_disk.as_u64().unwrap() as f64 / 1024f64.powi(3); + let movies_vec = &app.data.radarr_data.movies.items; + + if !movies_vec.is_empty() { + let rows = movies_vec.iter().map(|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()); + + Row::new(vec![ + Cell::from(movie.title.to_owned()), + Cell::from(movie.year.to_string()), + Cell::from(format!("{}h {}m", hours, minutes)), + Cell::from(format!("{:.2} GB", file_size)), + Cell::from( + app + .data + .radarr_data + .quality_profile_map + .get(&movie.quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(), + ), + ]) + .style(determine_row_style(app, movie)) + }); - Row::new(vec![ - Cell::from(movie.title.to_owned()), - Cell::from(movie.year.to_string()), - Cell::from(format!("{:0width$}:{:0width$}", hours, minutes, width = 2)), - Cell::from(format!("{:.2} GB", file_size)), - Cell::from(app.data.radarr_data.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); + .style(style_default()) + .bottom_margin(0); + let constraints = vec![ - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), - Constraint::Percentage(20), + 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); + .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) + f.render_stateful_widget(table, area, &mut app.data.radarr_data.movies.state); + } else { + loading(f, block, area, app.is_loading); + } +} + +pub(super) fn draw_movie_details(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let block = title_block("Movie Details"); + let movie_details = &app.data.radarr_data.movie_details.get_text(); + + if !movie_details.is_empty() { + let mut text = Text::from(movie_details.clone()); + text.patch_style(style_primary()); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((app.data.radarr_data.movie_details.offset, 0)); + + f.render_widget(paragraph, area); + } else { + loading(f, block, area, app.is_loading); + } } pub(super) fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect) { + let block = title_block("Stats"); + + if !app.data.radarr_data.version.is_empty() { let RadarrData { - free_space, - total_space, - start_time, - .. + free_space, + total_space, + start_time, + .. } = app.data.radarr_data; let ratio = if total_space == 0 { - 0f64 + 0f64 } else { - 1f64 - (free_space as f64 / total_space as f64) + 1f64 - (free_space as f64 / total_space as f64) }; - f.render_widget(title_block("Stats"), area); + f.render_widget(block, area); - let chunks = - vertical_chunks_with_margin(vec![ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2)], area, 1); + let chunks = vertical_chunks_with_margin( + vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(2), + ], + area, + 1, + ); let version_paragraph = Paragraph::new(Text::from(format!( - "Radarr Version: {}", - app.data.radarr_data.version - ))).block(Block::default()); + "Radarr Version: {}", + app.data.radarr_data.version + ))) + .block(Block::default()); let uptime = Utc::now().sub(start_time); let days = uptime.num_days(); @@ -89,50 +138,62 @@ pub(super) fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect let hours = day_difference.num_hours(); let hour_difference = day_difference.sub(Duration::hours(hours)); let minutes = hour_difference.num_minutes(); - let seconds = hour_difference.sub(Duration::minutes(minutes)).num_seconds(); + let seconds = hour_difference + .sub(Duration::minutes(minutes)) + .num_seconds(); let uptime_paragraph = Paragraph::new(Text::from(format!( - "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", - days, hours, minutes, seconds, width = 2 - ))).block(Block::default()); + "Uptime: {}d {:0width$}:{:0width$}:{:0width$}", + days, + hours, + minutes, + seconds, + width = 2 + ))) + .block(Block::default()); let space_gauge = LineGauge::default() - .block(Block::default().title("Storage:")) - .gauge_style(Style::default().fg(Color::Cyan)) - .line_set(symbols::line::THICK) - .ratio(ratio) - .label(Spans::from(format!("{:.0}%", ratio * 100.0))); + .block(Block::default().title("Storage:")) + .gauge_style(Style::default().fg(Color::Cyan)) + .line_set(symbols::line::THICK) + .ratio(ratio) + .label(Spans::from(format!("{:.0}%", ratio * 100.0))); f.render_widget(version_paragraph, chunks[0]); f.render_widget(uptime_paragraph, chunks[1]); f.render_widget(space_gauge, chunks[2]); + } else { + loading(f, block, area, app.is_loading); + } } pub(super) fn draw_logo(f: &mut Frame<'_, B>, area: Rect) { - let chunks = vertical_chunks( - vec![Constraint::Percentage(60), Constraint::Percentage(40)], - area, - ); - let logo = Paragraph::new(Text::from(RADARR_LOGO)) - .block(Block::default()) - .alignment(Alignment::Center); + let chunks = vertical_chunks( + vec![Constraint::Percentage(60), Constraint::Percentage(40)], + area, + ); + let logo = Paragraph::new(Text::from(RADARR_LOGO)) + .block(Block::default()) + .alignment(Alignment::Center); - f.render_widget(logo, chunks[0]); + f.render_widget(logo, chunks[0]); } fn determine_row_style(app: &App, movie: &Movie) -> Style { - let downloads_vec = &app.data.radarr_data.downloads.items; + let downloads_vec = &app.data.radarr_data.downloads.items; - if !movie.has_file { - if let Some(download) = downloads_vec.iter() - .find(|&download | download.movie_id == movie.id) { - if download.status == "downloading" { - return style_secondary() - } - } - - return style_tertiary() + if !movie.has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.movie_id == movie.id) + { + if download.status == "downloading" { + return style_secondary(); + } } - style_primary() -} \ No newline at end of file + return style_tertiary(); + } + + style_primary() +} diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 6186f64..5608d7c 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -50,37 +50,63 @@ pub fn vertical_chunks_with_margin( } pub fn layout_block(title_span: Span<'_>) -> Block<'_> { - Block::default().borders(Borders::ALL).title(title_span) + Block::default().borders(Borders::ALL).title(title_span) } pub fn style_bold() -> Style { - Style::default().add_modifier(Modifier::BOLD) + Style::default().add_modifier(Modifier::BOLD) } pub fn style_highlight() -> Style { - Style::default().add_modifier(Modifier::REVERSED) + Style::default().add_modifier(Modifier::REVERSED) } pub fn style_default() -> Style { - Style::default().fg(Color::White) + Style::default().fg(Color::White) } pub fn style_primary() -> Style { - Style::default().fg(Color::Green) + Style::default().fg(Color::Green) } pub fn style_secondary() -> Style { - Style::default().fg(Color::Magenta) + Style::default().fg(Color::Magenta) } pub fn style_tertiary() -> Style { - Style::default().fg(Color::Red) + Style::default().fg(Color::Red) } pub fn title_style(title: &str) -> Span<'_> { - Span::styled(title, style_bold()) + Span::styled(title, style_bold()) } pub fn title_block(title: &str) -> Block<'_> { - layout_block(title_style(title)) + layout_block(title_style(title)) +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] } diff --git a/src/utils.rs b/src/utils.rs index b0dae80..30e8a82 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,19 +1,33 @@ +use log::LevelFilter; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; -use log::LevelFilter; +use serde_json::Number; pub fn init_logging_config() -> log4rs::Config { - let file_path = "/tmp/managarr.log"; - let logfile = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) - .build(file_path) - .unwrap(); + let file_path = "/tmp/managarr.log"; + let logfile = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) + .build(file_path) + .unwrap(); - log4rs::Config::builder() - .appender(Appender::builder().build("logfile", Box::new(logfile))) - .build(Root::builder() - .appender("logfile") - .build(LevelFilter::Debug)) - .unwrap() -} \ No newline at end of file + log4rs::Config::builder() + .appender(Appender::builder().build("logfile", Box::new(logfile))) + .build( + Root::builder() + .appender("logfile") + .build(LevelFilter::Debug), + ) + .unwrap() +} + +pub fn convert_to_gb(bytes: u64) -> f64 { + bytes as f64 / 1024f64.powi(3) +} + +pub fn convert_runtime(runtime: u64) -> (u64, u64) { + let hours = runtime / 60; + let minutes = runtime % 60; + + (hours, minutes) +}