From 1ebf48132655d90450d6ef58bb37d98878a4e2ee Mon Sep 17 00:00:00 2001 From: Dark-Alex-17 Date: Tue, 8 Aug 2023 10:50:04 -0600 Subject: [PATCH] Initial Radarr ui! --- Cargo.toml | 1 + src/app/key_binding.rs | 16 +++++- src/app/mod.rs | 71 ++++++++++++++++++++++-- src/app/radarr.rs | 8 ++- src/event/key.rs | 10 ++++ src/handlers/mod.rs | 19 +++++++ src/main.rs | 14 ++--- src/network/mod.rs | 2 + src/network/radarr.rs | 123 ++++++++++++++++++++++------------------- src/network/utils.rs | 6 ++ src/ui/mod.rs | 57 +++++++++++++++---- 11 files changed, 245 insertions(+), 82 deletions(-) create mode 100644 src/network/utils.rs diff --git a/Cargo.toml b/Cargo.toml index adf9d5a..4137051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.0.30", features = ["help", "usage", "error-context", "derive"] } confy = { version = "0.5.1", default_features = false, features = ["yaml_conf"] } crossterm = "0.25.0" +derivative = "2.2.0" log = "0.4.17" log4rs = { version = "1.2.0", features = ["file_appender"] } reqwest = { version = "0.11.13", features = ["json"] } diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 378ffc8..6e38e42 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -9,17 +9,27 @@ macro_rules! generate_keybindings { } generate_keybindings! { - quit + quit, + up, + down } pub struct KeyBinding { - key: Key, - 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" } }; \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs index d3b74a0..d58330f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,6 +2,7 @@ use log::error; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::Sender; +use tui::widgets::TableState; use crate::app::radarr::RadarrData; @@ -10,7 +11,6 @@ use super::network::RadarrEvent; pub(crate) mod key_binding; pub mod radarr; -#[derive(Debug)] pub struct App { network_tx: Option>, pub client: Client, @@ -39,15 +39,15 @@ impl App { } } - pub fn reset(&mut self) { + pub fn reset_tick_count(&mut self) { self.tick_count = 0; - // self.data = Data::default(); } pub async fn on_tick(&mut self) { if self.tick_count % self.tick_until_poll == 0 { self.dispatch(RadarrEvent::GetOverview).await; self.dispatch(RadarrEvent::GetStatus).await; + self.dispatch(RadarrEvent::GetMovies).await; } self.tick_count += 1; @@ -68,7 +68,7 @@ impl Default for App { } } -#[derive(Default, Debug)] +#[derive(Default)] pub struct Data { pub radarr_data: RadarrData, } @@ -94,3 +94,66 @@ 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/radarr.rs b/src/app/radarr.rs index c6b7d74..4bbf4d7 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -1,9 +1,13 @@ use chrono::{DateTime, Utc}; -#[derive(Default, Debug)] +use crate::app::StatefulTable; +use crate::network::radarr::Movie; + +#[derive(Default)] pub struct RadarrData { pub free_space: u64, pub total_space: u64, pub version: String, - pub start_time: DateTime + pub start_time: DateTime, + pub movies: StatefulTable } diff --git a/src/event/key.rs b/src/event/key.rs index 7ed7c47..3b0c5c4 100644 --- a/src/event/key.rs +++ b/src/event/key.rs @@ -5,6 +5,8 @@ use crossterm::event::{KeyCode, KeyEvent}; #[derive(Debug, PartialEq, Eq)] pub enum Key { + Up, + Down, Char(char), Unknown, } @@ -21,6 +23,14 @@ impl Display for Key { impl From for Key { fn from(key_event: KeyEvent) -> Self { match key_event { + KeyEvent { + code: KeyCode::Up, + .. + } => Key::Up, + KeyEvent { + code: KeyCode::Down, + .. + } => Key::Down, KeyEvent { code: KeyCode::Char(c), .. diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e69de29..eebc2f8 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -0,0 +1,19 @@ +use crate::app::App; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::event::Key; + +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, + _ => () + } +} + +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(); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2c93b0d..d300a52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,13 +8,11 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use log::{debug, info}; -use tokio::sync::mpsc::Receiver; use tokio::sync::{mpsc, Mutex}; +use tokio::sync::mpsc::Receiver; use tui::backend::CrosstermBackend; use tui::Terminal; -use utils::init_logging_config; - use crate::app::App; use crate::event::input_event::{Events, InputEvent}; use crate::event::Key; @@ -35,7 +33,7 @@ struct Cli {} #[tokio::main] async fn main() -> Result<()> { - log4rs::init_config(init_logging_config())?; + log4rs::init_config(utils::init_logging_config())?; Cli::parse(); let config = confy::load("managarr", "config")?; @@ -50,7 +48,7 @@ async fn main() -> Result<()> { info!("Checking if Radarr server is up and running..."); app.lock().await.dispatch(RadarrEvent::HealthCheck).await; - simple_ui(&app).await?; + start_ui(&app).await?; Ok(()) } @@ -65,7 +63,7 @@ async fn start_networking(mut network_rx: Receiver, app: &Arc>) -> Result<()> { +async fn start_ui(app: &Arc>) -> Result<()> { let mut stdout = io::stdout(); enable_raw_mode()?; @@ -79,13 +77,15 @@ async fn simple_ui(app: &Arc>) -> Result<()> { loop { let mut app = app.lock().await; - terminal.draw(|f| ui(f, &app))?; + terminal.draw(|f| ui(f, &mut app))?; match input_events.next()? { InputEvent::KeyEvent(key) => { if key == Key::Char('q') { break; } + + handlers::handle_key_events(key, &mut app).await; } InputEvent::Tick => app.on_tick().await, } diff --git a/src/network/mod.rs b/src/network/mod.rs index 4a5d059..b03e95e 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -6,12 +6,14 @@ use tokio::sync::Mutex; use crate::app::App; pub(crate) mod radarr; +mod utils; #[derive(Debug, Eq, PartialEq, Hash)] pub enum RadarrEvent { HealthCheck, GetOverview, GetStatus, + GetMovies, } pub struct Network<'a> { diff --git a/src/network/radarr.rs b/src/network/radarr.rs index 9686ec5..3443bc5 100644 --- a/src/network/radarr.rs +++ b/src/network/radarr.rs @@ -1,11 +1,14 @@ -use anyhow::Result; use chrono::{DateTime, Utc}; +use derivative::Derivative; use log::{debug, error}; use reqwest::RequestBuilder; -use serde::{Deserialize, Serialize}; +use serde::de::DeserializeOwned; +use serde::Deserialize; use serde_json::Number; -use crate::app::RadarrConfig; -use crate::network::{Network, RadarrEvent}; +use tokio::sync::MutexGuard; + +use crate::app::{App, RadarrConfig}; +use crate::network::{Network, RadarrEvent, utils}; impl RadarrEvent { const fn resource(self) -> &'static str { @@ -13,11 +16,12 @@ impl RadarrEvent { RadarrEvent::HealthCheck => "/health", RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetStatus => "/system/status", + RadarrEvent::GetMovies => "/movie", } } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct DiskSpace { pub path: String, @@ -33,41 +37,30 @@ struct SystemStatus { start_time: DateTime, } +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct Movie { + #[derivative(Default(value = "Number::from(0)"))] + pub id: Number, + pub title: String, + #[derivative(Default(value = "Number::from(0)"))] + pub year: Number, + pub monitored: bool, + pub has_file: bool, +} + impl<'a> Network<'a> { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { match radarr_event { - RadarrEvent::HealthCheck => { - self.healthcheck(RadarrEvent::HealthCheck.resource()).await; - } - RadarrEvent::GetOverview => match self.diskspace(RadarrEvent::GetOverview.resource()).await { - Ok(disk_space_vec) => { - let mut app = self.app.lock().await; - 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(); - } - Err(e) => { - error!("Failed to fetch disk space. {:?}", e); - } - }, - RadarrEvent::GetStatus => match self.status(RadarrEvent::GetStatus.resource()).await { - Ok(system_status) => { - let mut app = self.app.lock().await; - app.data.radarr_data.version = system_status.version; - app.data.radarr_data.start_time = system_status.start_time; - } - Err(e) => { - error!("Failed to fetch system status. {:?}", e); - } - }, + RadarrEvent::HealthCheck => self.healthcheck(RadarrEvent::HealthCheck.resource()).await, + RadarrEvent::GetOverview => self.diskspace(RadarrEvent::GetOverview.resource()).await, + RadarrEvent::GetStatus => self.status(RadarrEvent::GetStatus.resource()).await, + RadarrEvent::GetMovies => self.movies(RadarrEvent::GetMovies.resource()).await } let mut app = self.app.lock().await; - app.reset(); + app.reset_tick_count(); } async fn healthcheck(&self, resource: &str) { @@ -76,32 +69,30 @@ impl<'a> Network<'a> { } } - async fn diskspace(&self, resource: &str) -> Result> { - debug!("Handling diskspace event: {:?}", resource); + async fn diskspace(&self, resource: &str) { + self.handle_get_request::>(resource, | disk_space_vec, mut app | { + let DiskSpace { + free_space, + total_space, + .. + } = &disk_space_vec[0]; - Ok( - self - .call_radarr_api(resource) - .await - .send() - .await? - .json::>() - .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 status(&self, resource: &str) -> Result { - debug!("Handling system status event: {:?}", resource); + async fn 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; + } - Ok( - self - .call_radarr_api(resource) - .await - .send() - .await? - .json::() - .await?, - ) + async fn movies(&self, resource: &str) { + self.handle_get_request::>(resource, |movie_vec, mut app| { + app.data.radarr_data.movies.set_items(movie_vec); + }).await; } async fn call_radarr_api(&self, resource: &str) -> RequestBuilder { @@ -123,4 +114,24 @@ 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 movie response! {:?}", e) + } + } + Err(e) => error!("Failed to fetch movies. {:?}", e) + } + } } diff --git a/src/network/utils.rs b/src/network/utils.rs new file mode 100644 index 0000000..7ed33d1 --- /dev/null +++ b/src/network/utils.rs @@ -0,0 +1,6 @@ +use reqwest::Response; +use serde::de::DeserializeOwned; + +pub async fn parse_response(response: Response) -> Result { + response.json::().await +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 960d1f7..5fbef27 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,15 +1,15 @@ 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::Cyan; -use tui::style::Style; -use tui::text::{Spans, Text}; -use tui::widgets::{Block, Borders, LineGauge, Paragraph}; -use tui::{symbols, Frame}; +use tui::style::{Color, Modifier, Style}; +use tui::text::{Span, Spans, Text}; +use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table}; -use crate::app::radarr::RadarrData; use crate::app::App; +use crate::app::radarr::RadarrData; use crate::logos::{ BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO, }; @@ -19,14 +19,17 @@ use crate::ui::utils::{ mod utils; -pub fn ui(f: &mut Frame, app: &App) { - let main_chunks = vertical_chunks( +static HIGHLIGHT_SYMBOL: &str = "=> "; + +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 ); draw_context_row(f, app, main_chunks[0]); - f.render_widget(Block::default().borders(Borders::ALL), main_chunks[1]); + draw_radarr_ui(f, app, main_chunks[1]); } fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { @@ -49,6 +52,40 @@ fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { draw_logo(f, chunks[4]); } +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 row_style = Style::default().fg(Color::Cyan); + let rows = app.data.radarr_data.movies.items + .iter() + .map(|movie| Row::new(vec![ + Cell::from(movie.title.to_owned()), + Cell::from(movie.year.to_string()), + Cell::from(movie.monitored.to_string()), + Cell::from(movie.has_file.to_string()) + ]).style(row_style)); + let header_row = Row::new(vec!["Title", "Year", "Monitored", "Downloaded"]) + .style(Style::default().fg(Color::White)) + .bottom_margin(0); + let constraints = vec![ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]; + + let table = Table::new(rows) + .header(header_row) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .widths(&constraints); + + f.render_stateful_widget(table, area, &mut app.data.radarr_data.movies.state) +} + fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect) { let RadarrData { free_space, @@ -92,7 +129,7 @@ fn draw_stats(f: &mut Frame<'_, B>, app: &App, area: Rect) { let space_gauge = LineGauge::default() .block(Block::default().title("Storage:")) - .gauge_style(Style::default().fg(Cyan)) + .gauge_style(Style::default().fg(Color::Cyan)) .line_set(symbols::line::THICK) .ratio(ratio) .label(Spans::from(format!("{:.0}%", ratio * 100.0)));