diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..955169a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "managarr" +version = "0.1.0" +authors = ["Alex Clarke "] +description = "A TUI for managing *arr servers" +edition = "2021" + +[dependencies] +anyhow = "1.0.68" +chrono = "0.4" +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" +log = "0.4.17" +log4rs = { version = "1.2.0", features = ["file_appender"] } +reqwest = { version = "0.11.13", features = ["json"] } +serde_yaml = "0.9.16" +serde_json = "1.0.91" +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.24.1", features = ["full"] } +tui = "0.19.0" diff --git a/README.md b/README.md index 062dec0..d95665b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # managarr -A TUI for managing *arr servers built with Rust +A TUI for managing *arr servers. diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs new file mode 100644 index 0000000..378ffc8 --- /dev/null +++ b/src/app/key_binding.rs @@ -0,0 +1,25 @@ +use crate::event::Key; + +macro_rules! generate_keybindings { + ($($field:ident),+) => { + pub struct KeyBindings { + $(pub $field: KeyBinding),+ + } + }; +} + +generate_keybindings! { + quit +} + +pub struct KeyBinding { + key: Key, + desc: &'static str +} + +pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { + quit: KeyBinding { + key: Key::Char('q'), + desc: "Quit", + } +}; \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..d94cb22 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,99 @@ +use log::error; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::Sender; + +use crate::app::radarr::RadarrData; + +use super::network::RadarrEvent; + +pub mod radarr; +pub(crate) mod key_binding; + +#[derive(Debug)] +pub struct App { + network_tx: Option>, + pub client: Client, + pub title: &'static str, + pub tick_until_poll: u64, + pub tick_count: u64, + pub config: AppConfig, + pub data: Data, +} + +impl App { + pub fn new( + network_tx: Sender, + tick_until_poll: u64, + config: AppConfig + ) -> Self { + App { + network_tx: Some(network_tx), + tick_until_poll, + config, + ..App::default() + } + } + + pub async fn dispatch(&mut self, action: RadarrEvent) { + if let Some(network_tx) = &self.network_tx { + if let Err(e) = network_tx.send(action).await { + error!("Failed to send event. {:?}", e); + } + } + } + + pub fn reset(&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.tick_count += 1; + } +} + +impl Default for App { + fn default() -> Self { + App { + network_tx: None, + client: Client::new(), + title: "DevTools", + tick_until_poll: 0, + tick_count: 0, + config: AppConfig::default(), + data: Data::default() + } + } +} + +#[derive(Default, Debug)] +pub struct Data { + pub radarr_data: RadarrData, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct AppConfig { + pub radarr: RadarrConfig, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RadarrConfig { + pub host: String, + pub port: Option, + pub api_token: String +} + +impl Default for RadarrConfig { + fn default() -> Self { + RadarrConfig { + host: "localhost".to_string(), + port: Some(7878), + api_token: "".to_string() + } + } +} \ No newline at end of file diff --git a/src/app/radarr.rs b/src/app/radarr.rs new file mode 100644 index 0000000..45b09ab --- /dev/null +++ b/src/app/radarr.rs @@ -0,0 +1,16 @@ +use crate::network::radarr::DiskSpace; + +#[derive(Default, Debug)] +pub struct RadarrData { + pub free_space: u64, + pub total_space: u64, +} + +impl From<&DiskSpace> for RadarrData { + fn from(disk_space: &DiskSpace) -> Self { + RadarrData { + free_space: disk_space.free_space.as_u64().unwrap(), + total_space: disk_space.total_space.as_u64().unwrap() + } + } +} diff --git a/src/event/input_event.rs b/src/event/input_event.rs new file mode 100644 index 0000000..3e8afa3 --- /dev/null +++ b/src/event/input_event.rs @@ -0,0 +1,52 @@ +use std::sync::mpsc; +use std::sync::mpsc::{Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event; +use crossterm::event::Event as CrosstermEvent; + +use crate::event::Key; + +pub enum InputEvent { + KeyEvent(T), + Tick +} + +pub struct Events { + _tx: Sender>, + rx: Receiver> +} + +impl Events { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + let tick_rate: Duration = Duration::from_millis(250); + + let event_tx = tx.clone(); + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + let timeout = tick_rate.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); + event_tx.send(InputEvent::KeyEvent(key)).unwrap(); + } + } + + if last_tick.elapsed() >= tick_rate { + event_tx.send(InputEvent::Tick).unwrap(); + last_tick = Instant::now(); + } + } + }); + + Events { _tx: tx, rx } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } +} \ No newline at end of file diff --git a/src/event/key.rs b/src/event/key.rs new file mode 100644 index 0000000..a645f63 --- /dev/null +++ b/src/event/key.rs @@ -0,0 +1,48 @@ +use std::fmt; +use std::fmt::{Display, Formatter}; + +use crossterm::event::{KeyCode, KeyEvent}; + +#[derive(Debug, PartialEq, Eq)] +pub enum Key { + Char(char), + Unknown +} + +impl Display for Key { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + Key::Char(c) => write!(f, "<{}>", c), + _ => write!(f, "<{:?}>", self) + } + } +} + +impl From for Key { + fn from(key_event: KeyEvent) -> Self { + match key_event { + KeyEvent { + code: KeyCode::Char(c), + .. + } => Key::Char(c), + _ => Key::Unknown + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent}; + + use crate::event::key::Key; + + #[test] + fn test_key_formatter() { + assert_eq!(format!("{}", Key::Char('q')), ""); + } + + #[test] + fn test_key_from() { + assert_eq!(Key::from(KeyEvent::from(KeyCode::Char('q'))), Key::Char('q')) + } +} \ No newline at end of file diff --git a/src/event/mod.rs b/src/event/mod.rs new file mode 100644 index 0000000..4a2132d --- /dev/null +++ b/src/event/mod.rs @@ -0,0 +1,8 @@ +pub use self::{ + input_event::{Events, InputEvent}, + key::Key +}; + +mod key; +pub mod input_event; + diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..06d9b99 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,106 @@ +use std::io; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use clap::Parser; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use log::{debug, info}; +use tokio::sync::{mpsc, Mutex}; +use tokio::sync::mpsc::Receiver; +use tui::Terminal; +use tui::backend::CrosstermBackend; + +use utils::init_logging_config; + +use crate::app::App; +use crate::event::input_event::{Events, InputEvent}; +use crate::event::Key; +use crate::network::{Network, RadarrEvent}; +use crate::ui::ui; + +mod app; +mod event; +mod handlers; +mod network; +mod ui; +mod utils; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + +} + +#[tokio::main] +async fn main() -> Result<()> { + log4rs::init_config(init_logging_config())?; + Cli::parse(); + + let config = confy::load("managarr", "config")?; + let (sync_network_tx, sync_network_rx) = mpsc::channel(500); + + let app = Arc::new(Mutex::new(App::new( + sync_network_tx, + 5000 / 250, + config + ))); + + let app_nw = Arc::clone(&app); + + std::thread::spawn(move || start_networking(sync_network_rx, &app_nw)); + + info!("Checking if Radarr server is up and running..."); + app.lock().await.dispatch(RadarrEvent::HealthCheck).await; + + simple_ui(&app).await?; + + Ok(()) +} + +#[tokio::main] +async fn start_networking(mut network_rx: Receiver, app: &Arc>) { + let network = Network::new(reqwest::Client::new(), app); + + while let Some(network_event) = network_rx.recv().await { + debug!("Received network event: {:?}", network_event); + network.handle_radarr_event(network_event).await; + } +} + +async fn simple_ui(app: &Arc>) -> Result<()> { + let mut stdout = io::stdout(); + enable_raw_mode()?; + + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + terminal.hide_cursor()?; + + let input_events = Events::new(); + + loop { + let mut app = app.lock().await; + terminal.draw(|f| ui(f, &app))?; + + match input_events.next()? { + InputEvent::KeyEvent(key) => { + if key == Key::Char('q') { + break; + } + } + InputEvent::Tick => { + app.on_tick().await + } + } + } + + terminal.show_cursor()?; + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} \ No newline at end of file diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..cf9d7d4 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use reqwest::Client; +use tokio::sync::Mutex; + +use crate::app::App; + +pub(crate) mod radarr; + +#[derive(Debug, Eq, PartialEq, Hash)] +pub enum RadarrEvent { + HealthCheck, + GetOverview +} + +pub struct Network<'a> { + pub client: Client, + + pub app: &'a Arc> +} + +impl<'a> Network<'a> { + pub fn new(client: Client, app: &'a Arc>) -> Self { + Network { client, app } + } +} \ No newline at end of file diff --git a/src/network/radarr.rs b/src/network/radarr.rs new file mode 100644 index 0000000..81e2cc3 --- /dev/null +++ b/src/network/radarr.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use log::{debug, error}; +use reqwest::RequestBuilder; +use serde::{Deserialize, Serialize}; +use serde_json::Number; + +use crate::app::radarr::RadarrData; +use crate::app::RadarrConfig; +use crate::network::{Network, RadarrEvent}; + +impl RadarrEvent { + const fn resource(self) -> &'static str { + match self { + RadarrEvent::HealthCheck => "/health", + RadarrEvent::GetOverview => "/diskspace" + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DiskSpace { + pub path: String, + pub label: String, + pub free_space: Number, + pub total_space: Number +} + +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; + app.data.radarr_data = RadarrData::from(&disk_space_vec[0]); + } + Err(e) => { + error!("Failed to fetch disk space. {:?}", e); + } + } + } + } + + let mut app = self.app.lock().await; + app.reset(); + } + + async fn healthcheck(&self, resource: &str) { + if let Err(e) = self.call_radarr_api(resource).await.send().await { + error!("Healthcheck failed. {:?}", e) + } + } + + async fn diskspace(&self, resource: &str) -> Result> { + debug!("Handling diskspace event: {:?}", resource); + + Ok( + self.call_radarr_api(resource) + .await + .send() + .await? + .json::>() + .await? + ) + } + + async fn call_radarr_api(&self, resource: &str) -> RequestBuilder { + debug!("Creating RequestBuilder for resource: {:?}", resource); + let app = self.app.lock().await; + let RadarrConfig { host, port, api_token } = &app.config.radarr; + + app.client.get(format!("http://{}:{}/api/v3{}", host, port.unwrap_or(7878), resource)) + .header("X-Api-Key", api_token) + } +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..fd78b6a --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,31 @@ +use tui::backend::Backend; +use tui::Frame; +use tui::layout::{Constraint, Direction, Layout}; +use tui::style::{Color, Style}; +use tui::widgets::{Block, Borders, Gauge}; + +use crate::app::App; +use crate::app::radarr::RadarrData; + +pub fn ui(f: &mut Frame, app: &App) { + let RadarrData { free_space, total_space } = app.data.radarr_data; + let ratio = if total_space == 0 { + 0f64 + } else { + 1f64 - (free_space as f64 / total_space as f64) + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .split(f.size()); + + let gauge = Gauge::default() + .block(Block::default() + .title("Free Space") + .borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Cyan)) + .ratio(ratio); + + f.render_widget(gauge, chunks[0]); +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..027e85b --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,19 @@ +use log4rs::append::file::FileAppender; +use log4rs::config::{Appender, Root}; +use log4rs::encode::pattern::PatternEncoder; +use log::LevelFilter; + +pub fn init_logging_config() -> log4rs::Config { + let file_path = "/tmp/devtools.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