Initial Radarr ui!

This commit is contained in:
2023-08-08 10:50:04 -06:00
parent 3ae7e15961
commit 1ebf481326
11 changed files with 245 additions and 82 deletions
+1
View File
@@ -11,6 +11,7 @@ chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.0.30", features = ["help", "usage", "error-context", "derive"] } clap = { version = "4.0.30", features = ["help", "usage", "error-context", "derive"] }
confy = { version = "0.5.1", default_features = false, features = ["yaml_conf"] } confy = { version = "0.5.1", default_features = false, features = ["yaml_conf"] }
crossterm = "0.25.0" crossterm = "0.25.0"
derivative = "2.2.0"
log = "0.4.17" log = "0.4.17"
log4rs = { version = "1.2.0", features = ["file_appender"] } log4rs = { version = "1.2.0", features = ["file_appender"] }
reqwest = { version = "0.11.13", features = ["json"] } reqwest = { version = "0.11.13", features = ["json"] }
+13 -3
View File
@@ -9,17 +9,27 @@ macro_rules! generate_keybindings {
} }
generate_keybindings! { generate_keybindings! {
quit quit,
up,
down
} }
pub struct KeyBinding { pub struct KeyBinding {
key: Key, pub key: Key,
desc: &'static str pub desc: &'static str
} }
pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
quit: KeyBinding { quit: KeyBinding {
key: Key::Char('q'), key: Key::Char('q'),
desc: "Quit", desc: "Quit",
},
up: KeyBinding {
key: Key::Up,
desc: "Scroll up"
},
down: KeyBinding {
key: Key::Down,
desc: "Scroll down"
} }
}; };
+67 -4
View File
@@ -2,6 +2,7 @@ use log::error;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tui::widgets::TableState;
use crate::app::radarr::RadarrData; use crate::app::radarr::RadarrData;
@@ -10,7 +11,6 @@ use super::network::RadarrEvent;
pub(crate) mod key_binding; pub(crate) mod key_binding;
pub mod radarr; pub mod radarr;
#[derive(Debug)]
pub struct App { pub struct App {
network_tx: Option<Sender<RadarrEvent>>, network_tx: Option<Sender<RadarrEvent>>,
pub client: Client, 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.tick_count = 0;
// self.data = Data::default();
} }
pub async fn on_tick(&mut self) { pub async fn on_tick(&mut self) {
if self.tick_count % self.tick_until_poll == 0 { if self.tick_count % self.tick_until_poll == 0 {
self.dispatch(RadarrEvent::GetOverview).await; self.dispatch(RadarrEvent::GetOverview).await;
self.dispatch(RadarrEvent::GetStatus).await; self.dispatch(RadarrEvent::GetStatus).await;
self.dispatch(RadarrEvent::GetMovies).await;
} }
self.tick_count += 1; self.tick_count += 1;
@@ -68,7 +68,7 @@ impl Default for App {
} }
} }
#[derive(Default, Debug)] #[derive(Default)]
pub struct Data { pub struct Data {
pub radarr_data: RadarrData, pub radarr_data: RadarrData,
} }
@@ -94,3 +94,66 @@ impl Default for RadarrConfig {
} }
} }
} }
pub struct StatefulTable<T> {
pub state: TableState,
pub items: Vec<T>
}
impl<T> Default for StatefulTable<T> {
fn default() -> StatefulTable<T> {
StatefulTable {
state: TableState::default(),
items: Vec::new()
}
}
}
impl<T> StatefulTable<T> {
pub fn set_items(&mut self, items: Vec<T>) {
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));
}
}
+6 -2
View File
@@ -1,9 +1,13 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
#[derive(Default, Debug)] use crate::app::StatefulTable;
use crate::network::radarr::Movie;
#[derive(Default)]
pub struct RadarrData { pub struct RadarrData {
pub free_space: u64, pub free_space: u64,
pub total_space: u64, pub total_space: u64,
pub version: String, pub version: String,
pub start_time: DateTime<Utc> pub start_time: DateTime<Utc>,
pub movies: StatefulTable<Movie>
} }
+10
View File
@@ -5,6 +5,8 @@ use crossterm::event::{KeyCode, KeyEvent};
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum Key { pub enum Key {
Up,
Down,
Char(char), Char(char),
Unknown, Unknown,
} }
@@ -21,6 +23,14 @@ impl Display for Key {
impl From<KeyEvent> for Key { impl From<KeyEvent> for Key {
fn from(key_event: KeyEvent) -> Self { fn from(key_event: KeyEvent) -> Self {
match key_event { match key_event {
KeyEvent {
code: KeyCode::Up,
..
} => Key::Up,
KeyEvent {
code: KeyCode::Down,
..
} => Key::Down,
KeyEvent { KeyEvent {
code: KeyCode::Char(c), code: KeyCode::Char(c),
.. ..
+19
View File
@@ -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();
}
+7 -7
View File
@@ -8,13 +8,11 @@ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
}; };
use log::{debug, info}; use log::{debug, info};
use tokio::sync::mpsc::Receiver;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio::sync::mpsc::Receiver;
use tui::backend::CrosstermBackend; use tui::backend::CrosstermBackend;
use tui::Terminal; use tui::Terminal;
use utils::init_logging_config;
use crate::app::App; use crate::app::App;
use crate::event::input_event::{Events, InputEvent}; use crate::event::input_event::{Events, InputEvent};
use crate::event::Key; use crate::event::Key;
@@ -35,7 +33,7 @@ struct Cli {}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
log4rs::init_config(init_logging_config())?; log4rs::init_config(utils::init_logging_config())?;
Cli::parse(); Cli::parse();
let config = confy::load("managarr", "config")?; let config = confy::load("managarr", "config")?;
@@ -50,7 +48,7 @@ async fn main() -> Result<()> {
info!("Checking if Radarr server is up and running..."); info!("Checking if Radarr server is up and running...");
app.lock().await.dispatch(RadarrEvent::HealthCheck).await; app.lock().await.dispatch(RadarrEvent::HealthCheck).await;
simple_ui(&app).await?; start_ui(&app).await?;
Ok(()) Ok(())
} }
@@ -65,7 +63,7 @@ async fn start_networking(mut network_rx: Receiver<RadarrEvent>, app: &Arc<Mutex
} }
} }
async fn simple_ui(app: &Arc<Mutex<App>>) -> Result<()> { async fn start_ui(app: &Arc<Mutex<App>>) -> Result<()> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
enable_raw_mode()?; enable_raw_mode()?;
@@ -79,13 +77,15 @@ async fn simple_ui(app: &Arc<Mutex<App>>) -> Result<()> {
loop { loop {
let mut app = app.lock().await; let mut app = app.lock().await;
terminal.draw(|f| ui(f, &app))?; terminal.draw(|f| ui(f, &mut app))?;
match input_events.next()? { match input_events.next()? {
InputEvent::KeyEvent(key) => { InputEvent::KeyEvent(key) => {
if key == Key::Char('q') { if key == Key::Char('q') {
break; break;
} }
handlers::handle_key_events(key, &mut app).await;
} }
InputEvent::Tick => app.on_tick().await, InputEvent::Tick => app.on_tick().await,
} }
+2
View File
@@ -6,12 +6,14 @@ use tokio::sync::Mutex;
use crate::app::App; use crate::app::App;
pub(crate) mod radarr; pub(crate) mod radarr;
mod utils;
#[derive(Debug, Eq, PartialEq, Hash)] #[derive(Debug, Eq, PartialEq, Hash)]
pub enum RadarrEvent { pub enum RadarrEvent {
HealthCheck, HealthCheck,
GetOverview, GetOverview,
GetStatus, GetStatus,
GetMovies,
} }
pub struct Network<'a> { pub struct Network<'a> {
+67 -56
View File
@@ -1,11 +1,14 @@
use anyhow::Result;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derivative::Derivative;
use log::{debug, error}; use log::{debug, error};
use reqwest::RequestBuilder; use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize}; use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::Number; use serde_json::Number;
use crate::app::RadarrConfig; use tokio::sync::MutexGuard;
use crate::network::{Network, RadarrEvent};
use crate::app::{App, RadarrConfig};
use crate::network::{Network, RadarrEvent, utils};
impl RadarrEvent { impl RadarrEvent {
const fn resource(self) -> &'static str { const fn resource(self) -> &'static str {
@@ -13,11 +16,12 @@ impl RadarrEvent {
RadarrEvent::HealthCheck => "/health", RadarrEvent::HealthCheck => "/health",
RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetOverview => "/diskspace",
RadarrEvent::GetStatus => "/system/status", RadarrEvent::GetStatus => "/system/status",
RadarrEvent::GetMovies => "/movie",
} }
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DiskSpace { struct DiskSpace {
pub path: String, pub path: String,
@@ -33,41 +37,30 @@ struct SystemStatus {
start_time: DateTime<Utc>, start_time: DateTime<Utc>,
} }
#[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> { impl<'a> Network<'a> {
pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) {
match radarr_event { match radarr_event {
RadarrEvent::HealthCheck => { RadarrEvent::HealthCheck => self.healthcheck(RadarrEvent::HealthCheck.resource()).await,
self.healthcheck(RadarrEvent::HealthCheck.resource()).await; RadarrEvent::GetOverview => self.diskspace(RadarrEvent::GetOverview.resource()).await,
} RadarrEvent::GetStatus => self.status(RadarrEvent::GetStatus.resource()).await,
RadarrEvent::GetOverview => match self.diskspace(RadarrEvent::GetOverview.resource()).await { RadarrEvent::GetMovies => self.movies(RadarrEvent::GetMovies.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);
}
},
} }
let mut app = self.app.lock().await; let mut app = self.app.lock().await;
app.reset(); app.reset_tick_count();
} }
async fn healthcheck(&self, resource: &str) { async fn healthcheck(&self, resource: &str) {
@@ -76,32 +69,30 @@ impl<'a> Network<'a> {
} }
} }
async fn diskspace(&self, resource: &str) -> Result<Vec<DiskSpace>> { async fn diskspace(&self, resource: &str) {
debug!("Handling diskspace event: {:?}", resource); self.handle_get_request::<Vec<DiskSpace>>(resource, | disk_space_vec, mut app | {
let DiskSpace {
free_space,
total_space,
..
} = &disk_space_vec[0];
Ok( app.data.radarr_data.free_space = free_space.as_u64().unwrap();
self app.data.radarr_data.total_space = total_space.as_u64().unwrap();
.call_radarr_api(resource) }).await;
.await
.send()
.await?
.json::<Vec<DiskSpace>>()
.await?,
)
} }
async fn status(&self, resource: &str) -> Result<SystemStatus> { async fn status(&self, resource: &str) {
debug!("Handling system status event: {:?}", resource); self.handle_get_request::<SystemStatus>(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( async fn movies(&self, resource: &str) {
self self.handle_get_request::<Vec<Movie>>(resource, |movie_vec, mut app| {
.call_radarr_api(resource) app.data.radarr_data.movies.set_items(movie_vec);
.await }).await;
.send()
.await?
.json::<SystemStatus>()
.await?,
)
} }
async fn call_radarr_api(&self, resource: &str) -> RequestBuilder { async fn call_radarr_api(&self, resource: &str) -> RequestBuilder {
@@ -123,4 +114,24 @@ impl<'a> Network<'a> {
)) ))
.header("X-Api-Key", api_token) .header("X-Api-Key", api_token)
} }
async fn handle_get_request<T>(&self, resource: &str, mut app_update_fn: impl FnMut(T, MutexGuard<App>))
where
T: DeserializeOwned {
match self.call_radarr_api(resource)
.await
.send()
.await {
Ok(response) => {
match utils::parse_response::<T>(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)
}
}
} }
+6
View File
@@ -0,0 +1,6 @@
use reqwest::Response;
use serde::de::DeserializeOwned;
pub async fn parse_response<T: DeserializeOwned>(response: Response) -> Result<T, reqwest::Error> {
response.json::<T>().await
}
+47 -10
View File
@@ -1,15 +1,15 @@
use std::ops::Sub; use std::ops::Sub;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use tui::{Frame, symbols};
use tui::backend::Backend; use tui::backend::Backend;
use tui::layout::{Alignment, Constraint, Rect}; use tui::layout::{Alignment, Constraint, Rect};
use tui::style::Color::Cyan; use tui::style::{Color, Modifier, Style};
use tui::style::Style; use tui::text::{Span, Spans, Text};
use tui::text::{Spans, Text}; use tui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table};
use tui::widgets::{Block, Borders, LineGauge, Paragraph};
use tui::{symbols, Frame};
use crate::app::radarr::RadarrData;
use crate::app::App; use crate::app::App;
use crate::app::radarr::RadarrData;
use crate::logos::{ use crate::logos::{
BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO, BAZARR_LOGO, LIDARR_LOGO, PROWLARR_LOGO, RADARR_LOGO, READARR_LOGO, SONARR_LOGO,
}; };
@@ -19,14 +19,17 @@ use crate::ui::utils::{
mod utils; mod utils;
pub fn ui<B: Backend>(f: &mut Frame<B>, app: &App) { static HIGHLIGHT_SYMBOL: &str = "=> ";
let main_chunks = vertical_chunks(
pub fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
let main_chunks = vertical_chunks_with_margin(
vec![Constraint::Length(20), Constraint::Length(0)], vec![Constraint::Length(20), Constraint::Length(0)],
f.size(), f.size(),
1
); );
draw_context_row(f, app, main_chunks[0]); 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<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) { fn draw_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
@@ -49,6 +52,40 @@ fn draw_context_row<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
draw_logo(f, chunks[4]); draw_logo(f, chunks[4]);
} }
fn draw_radarr_ui<B: Backend>(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<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) { fn draw_stats<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
let RadarrData { let RadarrData {
free_space, free_space,
@@ -92,7 +129,7 @@ fn draw_stats<B: Backend>(f: &mut Frame<'_, B>, app: &App, area: Rect) {
let space_gauge = LineGauge::default() let space_gauge = LineGauge::default()
.block(Block::default().title("Storage:")) .block(Block::default().title("Storage:"))
.gauge_style(Style::default().fg(Cyan)) .gauge_style(Style::default().fg(Color::Cyan))
.line_set(symbols::line::THICK) .line_set(symbols::line::THICK)
.ratio(ratio) .ratio(ratio)
.label(Spans::from(format!("{:.0}%", ratio * 100.0))); .label(Spans::from(format!("{:.0}%", ratio * 100.0)));