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"] }
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"] }
+13 -3
View File
@@ -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"
}
};
+67 -4
View File
@@ -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<Sender<RadarrEvent>>,
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<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};
#[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<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)]
pub enum Key {
Up,
Down,
Char(char),
Unknown,
}
@@ -21,6 +23,14 @@ impl Display for Key {
impl From<KeyEvent> 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),
..
+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,
};
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<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();
enable_raw_mode()?;
@@ -79,13 +77,15 @@ async fn simple_ui(app: &Arc<Mutex<App>>) -> 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,
}
+2
View File
@@ -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> {
+67 -56
View File
@@ -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<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> {
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<Vec<DiskSpace>> {
debug!("Handling diskspace event: {:?}", resource);
async fn diskspace(&self, resource: &str) {
self.handle_get_request::<Vec<DiskSpace>>(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::<Vec<DiskSpace>>()
.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<SystemStatus> {
debug!("Handling system status event: {:?}", resource);
async fn status(&self, resource: &str) {
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(
self
.call_radarr_api(resource)
.await
.send()
.await?
.json::<SystemStatus>()
.await?,
)
async fn movies(&self, resource: &str) {
self.handle_get_request::<Vec<Movie>>(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<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 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<B: Backend>(f: &mut Frame<B>, app: &App) {
let main_chunks = vertical_chunks(
static HIGHLIGHT_SYMBOL: &str = "=> ";
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)],
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<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]);
}
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) {
let RadarrData {
free_space,
@@ -92,7 +129,7 @@ fn draw_stats<B: Backend>(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)));