use anyhow::anyhow; use log::{debug, error}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::Sender; use tokio::time::Instant; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState}; use crate::network::NetworkEvent; pub(crate) mod key_binding; pub(crate) mod radarr; const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies, None); pub struct App { navigation_stack: Vec, network_tx: Option>, pub server_tabs: TabState, pub error: HorizontallyScrollableText, pub response: String, pub client: Client, pub title: &'static str, pub tick_until_poll: u64, pub ticks_until_scroll: u64, pub tick_count: u64, pub last_tick: Instant, pub is_routing: bool, pub is_loading: bool, pub should_refresh: bool, pub should_ignore_quit_key: bool, pub config: AppConfig, pub data: Data, } impl App { pub fn new(network_tx: Sender, config: AppConfig) -> Self { App { network_tx: Some(network_tx), config, ..App::default() } } pub async fn dispatch_network_event(&mut self, action: NetworkEvent) { debug!("Dispatching network event: {:?}", action); 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); self.handle_error(anyhow!(e)); } } } pub fn reset_tick_count(&mut self) { self.tick_count = 0; } // Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then #[allow(dead_code)] pub fn reset(&mut self) { self.reset_tick_count(); self.error = HorizontallyScrollableText::default(); self.data = Data::default(); } pub fn handle_error(&mut self, error: anyhow::Error) { if self.error.text.is_empty() { self.error = error.to_string().into(); } } pub async fn on_tick(&mut self, is_first_render: bool) { if self.tick_count % self.tick_until_poll == 0 || self.is_routing || self.should_refresh { if let Route::Radarr(active_radarr_block, _) = self.get_current_route() { self .radarr_on_tick(*active_radarr_block, is_first_render) .await; } self.is_routing = false; self.should_refresh = false; } self.tick_count += 1; } pub fn push_navigation_stack(&mut self, route: Route) { self.navigation_stack.push(route); self.is_routing = true; } pub fn pop_navigation_stack(&mut self) { self.is_routing = true; if self.navigation_stack.len() > 1 { self.navigation_stack.pop(); } } pub fn pop_and_push_navigation_stack(&mut self, route: Route) { self.pop_navigation_stack(); self.push_navigation_stack(route); } pub fn get_current_route(&self) -> &Route { self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) } } impl Default for App { fn default() -> Self { App { navigation_stack: vec![DEFAULT_ROUTE], network_tx: None, error: HorizontallyScrollableText::default(), response: String::default(), server_tabs: TabState::new(vec![ TabRoute { title: "Radarr".to_owned(), route: ActiveRadarrBlock::Movies.into(), help: "<↑↓> scroll | ←→ change tab | change servarr | quit ".to_owned(), contextual_help: None, }, TabRoute { title: "Sonarr".to_owned(), route: Route::Sonarr, help: " change servarr | quit ".to_owned(), contextual_help: None, }, ]), client: Client::new(), title: "Managarr", tick_until_poll: 400, ticks_until_scroll: 4, tick_count: 0, last_tick: Instant::now(), is_loading: false, is_routing: false, should_refresh: false, should_ignore_quit_key: false, config: AppConfig::default(), data: Data::default(), } } } #[derive(Default)] 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(), } } } #[cfg(test)] mod tests { use anyhow::anyhow; use pretty_assertions::{assert_eq, assert_str_eq}; use tokio::sync::mpsc; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; use crate::app::{App, Data, RadarrConfig, DEFAULT_ROUTE}; use crate::models::{HorizontallyScrollableText, Route, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; #[test] fn test_app_default() { let app = App::default(); assert_eq!(app.navigation_stack, vec![DEFAULT_ROUTE]); assert!(app.network_tx.is_none()); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.response.is_empty()); assert_eq!(app.server_tabs.index, 0); assert_eq!( app.server_tabs.tabs, vec![ TabRoute { title: "Radarr".to_owned(), route: ActiveRadarrBlock::Movies.into(), help: "<↑↓> scroll | ←→ change tab | change servarr | quit ".to_owned(), contextual_help: None, }, TabRoute { title: "Sonarr".to_owned(), route: Route::Sonarr, help: " change servarr | quit ".to_owned(), contextual_help: None, } ] ); assert_str_eq!(app.title, "Managarr"); assert_eq!(app.tick_until_poll, 400); assert_eq!(app.ticks_until_scroll, 4); assert_eq!(app.tick_count, 0); assert!(!app.is_loading); assert!(!app.is_routing); assert!(!app.should_refresh); assert!(!app.should_ignore_quit_key); } #[test] fn test_navigation_stack_methods() { let mut app = App::default(); assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); app.push_navigation_stack(ActiveRadarrBlock::Downloads.into()); assert_eq!( app.get_current_route(), &ActiveRadarrBlock::Downloads.into() ); assert!(app.is_routing); app.is_routing = false; app.pop_and_push_navigation_stack(ActiveRadarrBlock::Collections.into()); assert_eq!( app.get_current_route(), &ActiveRadarrBlock::Collections.into() ); assert!(app.is_routing); app.is_routing = false; app.pop_navigation_stack(); assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); assert!(app.is_routing); app.is_routing = false; app.pop_navigation_stack(); assert_eq!(app.get_current_route(), &DEFAULT_ROUTE); assert!(app.is_routing); } #[test] fn test_reset_tick_count() { let mut app = App { tick_count: 2, ..App::default() }; app.reset_tick_count(); assert_eq!(app.tick_count, 0); } #[test] fn test_reset() { let mut app = App { tick_count: 2, error: "Test error".to_owned().into(), data: Data { radarr_data: RadarrData { version: "test".to_owned(), ..RadarrData::default() }, }, ..App::default() }; app.reset(); assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.data.radarr_data.version.is_empty()); } #[test] fn test_handle_error() { let mut app = App::default(); let test_string = "Testing"; app.handle_error(anyhow!(test_string)); assert_eq!(app.error.text, test_string); app.handle_error(anyhow!("Testing a different error")); assert_eq!(app.error.text, test_string); } #[tokio::test] async fn test_on_tick_first_render() { let (sync_network_tx, mut sync_network_rx) = mpsc::channel::(500); let mut app = App { tick_until_poll: 2, network_tx: Some(sync_network_tx), ..App::default() }; assert_eq!(app.tick_count, 0); app.on_tick(true).await; assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetQualityProfiles.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetTags.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetRootFolders.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetOverview.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetStatus.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetMovies.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetDownloads.into() ); assert!(!app.is_routing); assert!(!app.should_refresh); assert_eq!(app.tick_count, 1); } #[tokio::test] async fn test_on_tick_routing() { let mut app = App { tick_until_poll: 2, tick_count: 2, is_routing: true, ..App::default() }; app.on_tick(false).await; assert!(!app.is_routing); } #[tokio::test] async fn test_on_tick_should_refresh() { let mut app = App { tick_until_poll: 2, tick_count: 2, should_refresh: true, ..App::default() }; app.on_tick(false).await; assert!(!app.should_refresh); } #[test] fn test_radarr_config_default() { let radarr_config = RadarrConfig::default(); assert_str_eq!(radarr_config.host, "localhost"); assert_eq!(radarr_config.port, Some(7878)); assert!(radarr_config.api_token.is_empty()); } }