From bc3aeefa6e0dbdcb86995f86f18a258c56937237 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 13:10:30 -0700 Subject: [PATCH] feat: TUI support for Lidarr library --- src/app/app_tests.rs | 2 + src/app/context_clues.rs | 2 + src/app/lidarr/lidarr_context_clues.rs | 25 ++ src/app/lidarr/lidarr_context_clues_tests.rs | 92 ++++++ src/app/lidarr/lidarr_tests.rs | 28 ++ src/app/lidarr/mod.rs | 97 +++++++ src/app/mod.rs | 6 +- .../library/library_handler_tests.rs | 272 ++++++++++++++++++ src/handlers/lidarr_handlers/library/mod.rs | 211 ++++++++++++++ .../lidarr_handlers/lidarr_handler_tests.rs | 15 + src/handlers/lidarr_handlers/mod.rs | 102 +++++++ src/handlers/mod.rs | 8 + src/models/lidarr_models.rs | 104 ++++++- src/models/lidarr_models_tests.rs | 1 - src/models/servarr_data/lidarr/lidarr_data.rs | 115 +++++++- src/models/stateful_table.rs | 18 +- src/models/stateful_table_tests.rs | 41 +++ .../library/lidarr_library_network_tests.rs | 44 +++ src/network/lidarr_network/library/mod.rs | 36 +++ .../lidarr_network/lidarr_network_tests.rs | 60 +--- src/network/lidarr_network/mod.rs | 77 ++--- .../system/lidarr_system_network_tests.rs | 246 ++++++++++++++++ src/network/lidarr_network/system/mod.rs | 164 +++++++++++ .../sonarr_network/library/series/mod.rs | 2 +- src/ui/lidarr_ui/library/library_ui_tests.rs | 20 ++ src/ui/lidarr_ui/library/mod.rs | 185 ++++++++++++ src/ui/lidarr_ui/lidarr_ui_tests.rs | 16 ++ src/ui/lidarr_ui/mod.rs | 209 ++++++++++++++ src/ui/mod.rs | 6 + 29 files changed, 2113 insertions(+), 91 deletions(-) create mode 100644 src/app/lidarr/lidarr_context_clues.rs create mode 100644 src/app/lidarr/lidarr_context_clues_tests.rs create mode 100644 src/app/lidarr/lidarr_tests.rs create mode 100644 src/app/lidarr/mod.rs create mode 100644 src/handlers/lidarr_handlers/library/library_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/library/mod.rs create mode 100644 src/handlers/lidarr_handlers/lidarr_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/mod.rs create mode 100644 src/network/lidarr_network/library/lidarr_library_network_tests.rs create mode 100644 src/network/lidarr_network/library/mod.rs create mode 100644 src/network/lidarr_network/system/lidarr_system_network_tests.rs create mode 100644 src/network/lidarr_network/system/mod.rs create mode 100644 src/ui/lidarr_ui/library/library_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/mod.rs create mode 100644 src/ui/lidarr_ui/lidarr_ui_tests.rs create mode 100644 src/ui/lidarr_ui/mod.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 6d9b1f8..f1a3d5d 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -8,6 +8,7 @@ mod tests { use tokio::sync::mpsc; use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; + use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; @@ -185,6 +186,7 @@ mod tests { ..SonarrData::default() }; let data = Data { + lidarr_data: LidarrData::default(), radarr_data, sonarr_data, }; diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index f58f901..a7f0f4c 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -1,5 +1,6 @@ use crate::app::App; use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; +use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider; use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider; use crate::models::Route; @@ -21,6 +22,7 @@ impl ContextClueProvider for ServarrContextClueProvider { match app.get_current_route() { Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app), Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app), + Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app), _ => None, } } diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs new file mode 100644 index 0000000..a2cdabe --- /dev/null +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -0,0 +1,25 @@ +use crate::app::App; +use crate::app::context_clues::{ContextClue, ContextClueProvider}; +use crate::models::Route; + +#[cfg(test)] +#[path = "lidarr_context_clues_tests.rs"] +mod lidarr_context_clues_tests; + +pub(in crate::app) struct LidarrContextClueProvider; + +impl ContextClueProvider for LidarrContextClueProvider { + fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> { + let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() else { + panic!("LidarrContextClueProvider::get_context_clues called with non-Lidarr route"); + }; + + match active_lidarr_block { + _ => app + .data + .lidarr_data + .main_tabs + .get_active_route_contextual_help(), + } + } +} diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs new file mode 100644 index 0000000..6f7f177 --- /dev/null +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -0,0 +1,92 @@ +#[cfg(test)] +mod tests { + use crate::app::context_clues::ContextClueProvider; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, ARTISTS_CONTEXT_CLUES, + }; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + + #[test] + fn test_artists_context_clues() { + let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter") + ); + assert_none!(artists_context_clues_iter.next()); + } + + #[test] + #[should_panic( + expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route" + )] + fn test_lidarr_context_clue_provider_get_context_clues_non_lidarr_route() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::default().into()); + + LidarrContextClueProvider::get_context_clues(&mut app); + } + + #[test] + fn test_lidarr_context_clue_provider_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_artists_sort_prompt_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistsSortPrompt.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_search_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::SearchArtists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_filter_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::FilterArtists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } +} diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs new file mode 100644 index 0000000..75b282e --- /dev/null +++ b/src/app/lidarr/lidarr_tests.rs @@ -0,0 +1,28 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::network::NetworkEvent; + use crate::network::lidarr_network::LidarrEvent; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_dispatch_by_lidarr_block_artists() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.network_tx = Some(tx); + + app.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists).await; + + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into()); + } +} diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs new file mode 100644 index 0000000..4caaaea --- /dev/null +++ b/src/app/lidarr/mod.rs @@ -0,0 +1,97 @@ +use crate::{ + models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, + network::lidarr_network::LidarrEvent, +}; + +use super::App; + +pub(in crate::app) mod lidarr_context_clues; + +#[cfg(test)] +#[path = "lidarr_tests.rs"] +mod lidarr_tests; + +impl App<'_> { + pub(super) async fn dispatch_by_lidarr_block(&mut self, active_lidarr_block: &ActiveLidarrBlock) { + match active_lidarr_block { + ActiveLidarrBlock::Artists => { + self + .dispatch_network_event(LidarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(LidarrEvent::ListArtists.into()) + .await; + } + _ => (), + } + + self.check_for_lidarr_prompt_action().await; + self.reset_tick_count(); + } + + async fn check_for_lidarr_prompt_action(&mut self) { + if self.data.lidarr_data.prompt_confirm { + self.data.lidarr_data.prompt_confirm = false; + if let Some(lidarr_event) = self.data.lidarr_data.prompt_confirm_action.take() { + self.dispatch_network_event(lidarr_event.into()).await; + self.should_refresh = true; + } + } + } + + pub(super) async fn lidarr_on_tick(&mut self, active_lidarr_block: ActiveLidarrBlock) { + if self.is_first_render { + self.refresh_lidarr_metadata().await; + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + self.is_first_render = false; + return; + } + + if self.should_refresh { + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + self.refresh_lidarr_metadata().await; + } + + if self.is_routing { + if !self.should_refresh { + self.cancellation_token.cancel(); + } else { + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + } + } + + if self.tick_count.is_multiple_of(self.tick_until_poll) { + self.refresh_lidarr_metadata().await; + } + } + + async fn refresh_lidarr_metadata(&mut self) { + self + .dispatch_network_event(LidarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetRootFolders.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetDownloads(500).into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetDiskSpace.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetStatus.into()) + .await; + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 68edf1f..c40e0fb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use veil::Redact; use crate::cli::Command; -use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_models::KeybindingItem; @@ -26,6 +26,7 @@ mod app_tests; pub mod context_clues; pub mod key_binding; mod key_binding_tests; +pub mod lidarr; pub mod radarr; pub mod sonarr; @@ -197,6 +198,7 @@ impl App<'_> { match self.get_current_route() { Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await, + Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await, _ => (), } @@ -299,6 +301,7 @@ impl App<'_> { pub fn test_default_fully_populated() -> Self { App { data: Data { + lidarr_data: LidarrData::test_default_fully_populated(), radarr_data: RadarrData::test_default_fully_populated(), sonarr_data: SonarrData::test_default_fully_populated(), }, @@ -329,6 +332,7 @@ impl App<'_> { #[derive(Default)] pub struct Data<'a> { + pub lidarr_data: LidarrData<'a>, pub radarr_data: RadarrData<'a>, pub sonarr_data: SonarrData<'a>, } diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs new file mode 100644 index 0000000..da5784b --- /dev/null +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,272 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use pretty_assertions::assert_str_eq; + use serde_json::Number; + use strum::IntoEnumIterator; + + use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; + use crate::handlers::KeyEventHandler; + use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + + #[test] + fn test_library_handler_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + if LIBRARY_BLOCKS.contains(&lidarr_block) { + assert!(LibraryHandler::accepts(lidarr_block)); + } else { + assert!(!LibraryHandler::accepts(lidarr_block)); + } + } + } + + #[test] + fn test_artists_sorting_options_name() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.artist_name + .text + .to_lowercase() + .cmp(&b.artist_name.text.to_lowercase()) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[0].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Name"); + } + + #[test] + fn test_artists_sorting_options_type() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[1].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Type"); + } + + #[test] + fn test_artists_sorting_options_status() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[2].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Status"); + } + + #[test] + fn test_artists_sorting_options_quality_profile() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[3].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Quality Profile"); + } + + #[test] + fn test_artists_sorting_options_metadata_profile() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = + |a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[4].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Metadata Profile"); + } + + #[test] + fn test_artists_sorting_options_albums() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.album_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[5].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Albums"); + } + + #[test] + fn test_artists_sorting_options_tracks() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.track_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_count)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[6].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Tracks"); + } + + #[test] + fn test_artists_sorting_options_size() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[7].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_artists_sorting_options_monitored() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[8].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_artists_sorting_options_tags() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + a_str.cmp(&b_str) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[9].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + fn artists_vec() -> Vec { + vec![ + Artist { + id: 3, + artist_name: "Test Artist 1".into(), + artist_type: Some("Group".to_owned()), + status: ArtistStatus::Ended, + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: false, + tags: vec![Number::from(1), Number::from(2)], + statistics: Some(ArtistStatistics { + album_count: 5, + track_count: 50, + size_on_disk: 789, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + Artist { + id: 2, + artist_name: "Test Artist 2".into(), + artist_type: Some("Solo".to_owned()), + status: ArtistStatus::Continuing, + quality_profile_id: 2, + metadata_profile_id: 2, + monitored: false, + tags: vec![Number::from(1), Number::from(3)], + statistics: Some(ArtistStatistics { + album_count: 10, + track_count: 100, + size_on_disk: 456, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + Artist { + id: 1, + artist_name: "Test Artist 3".into(), + artist_type: None, + status: ArtistStatus::Deleted, + quality_profile_id: 3, + metadata_profile_id: 3, + monitored: true, + tags: vec![Number::from(2), Number::from(3)], + statistics: Some(ArtistStatistics { + album_count: 3, + track_count: 30, + size_on_disk: 123, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + ] + } +} diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs new file mode 100644 index 0000000..348fc49 --- /dev/null +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -0,0 +1,211 @@ +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_clear_errors}, + matches_key, + models::{ + lidarr_models::Artist, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}, + stateful_table::SortOption, + }, +}; + +use super::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; + +#[cfg(test)] +#[path = "library_handler_tests.rs"] +mod library_handler_tests; + +pub(super) struct LibraryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + let artists_table_handling_config = + TableHandlingConfig::new(ActiveLidarrBlock::Artists.into()) + .sorting_block(ActiveLidarrBlock::ArtistsSortPrompt.into()) + .sort_options(artists_sorting_options()) + .searching_block(ActiveLidarrBlock::SearchArtists.into()) + .search_error_block(ActiveLidarrBlock::SearchArtistsError.into()) + .search_field_fn(|artist| &artist.artist_name.text) + .filtering_block(ActiveLidarrBlock::FilterArtists.into()) + .filter_error_block(ActiveLidarrBlock::FilterArtistsError.into()) + .filter_field_fn(|artist| &artist.artist_name.text); + + if !handle_table( + self, + |app| &mut app.data.lidarr_data.artists, + artists_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + LIBRARY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_lidarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.lidarr_data.artists.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::Artists { + handle_change_tab_left_right_keys(self.app, self.key); + } + } + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + handle_clear_errors(self.app); + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_lidarr_block == ActiveLidarrBlock::Artists && matches_key!(refresh, key) { + self.app.should_refresh = true; + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> crate::models::Route { + self.app.get_current_route() + } +} + +fn artists_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Name", + cmp_fn: Some(|a, b| { + a.artist_name + .text + .to_lowercase() + .cmp(&b.artist_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Type", + cmp_fn: Some(|a, b| { + a.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }), + }, + SortOption { + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality Profile", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Metadata Profile", + cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)), + }, + SortOption { + name: "Albums", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.album_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count)) + }), + }, + SortOption { + name: "Tracks", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.track_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_count)) + }), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }), + }, + SortOption { + name: "Monitored", + cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)), + }, + SortOption { + name: "Tags", + cmp_fn: Some(|a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }), + }, + ] +} diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs new file mode 100644 index 0000000..34af245 --- /dev/null +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::LidarrHandler; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + + #[test] + fn test_lidarr_handler_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + assert!(LidarrHandler::accepts(lidarr_block)); + } + } +} diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs new file mode 100644 index 0000000..0002e73 --- /dev/null +++ b/src/handlers/lidarr_handlers/mod.rs @@ -0,0 +1,102 @@ +use library::LibraryHandler; + +use crate::{ + app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, +}; + +use super::KeyEventHandler; + +mod library; + +#[cfg(test)] +#[path = "lidarr_handler_tests.rs"] +mod lidarr_handler_tests; + +pub(super) struct LidarrHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_lidarr_block { + _ if LibraryHandler::accepts(self.active_lidarr_block) => { + LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + + fn accepts(_active_block: ActiveLidarrBlock) -> bool { + true + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> LidarrHandler<'a, 'b> { + LidarrHandler { + key, + app, + active_lidarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + true + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) {} + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> crate::models::Route { + self.app.get_current_route() + } +} + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => { + app.data.lidarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route()); + } + _ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => { + app.data.lidarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c64ef20..316ba13 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ +use lidarr_handlers::LidarrHandler; use radarr_handlers::RadarrHandler; use sonarr_handlers::SonarrHandler; @@ -15,6 +16,7 @@ use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route}; mod keybinding_handler; +mod lidarr_handlers; mod radarr_handlers; mod sonarr_handlers; @@ -125,6 +127,9 @@ pub fn handle_events(key: Key, app: &mut App<'_>) { Route::Sonarr(active_sonarr_block, context) => { SonarrHandler::new(key, app, active_sonarr_block, context).handle() } + Route::Lidarr(active_lidarr_block, context) => { + LidarrHandler::new(key, app, active_lidarr_block, context).handle() + } _ => (), } } @@ -187,6 +192,9 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { Route::Sonarr(_, _) => { app.data.sonarr_data.prompt_confirm = !app.data.sonarr_data.prompt_confirm } + Route::Lidarr(_, _) => { + app.data.lidarr_data.prompt_confirm = !app.data.lidarr_data.prompt_confirm + } _ => (), }, _ => (), diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index f17d382..3ed7747 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, Utc}; use derivative::Derivative; +use enum_display_style_derive::EnumDisplayStyle; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; +use strum::EnumIter; use super::{HorizontallyScrollableText, Serdeable}; use crate::serde_enum_from; @@ -15,7 +17,6 @@ mod lidarr_models_tests; pub struct Artist { #[serde(deserialize_with = "super::from_i64")] pub id: i64, - pub mb_id: String, pub artist_name: HorizontallyScrollableText, pub foreign_artist_id: String, pub status: ArtistStatus, @@ -35,8 +36,20 @@ pub struct Artist { pub statistics: Option, } -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug)] +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + strum::Display, + EnumDisplayStyle, +)] #[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] pub enum ArtistStatus { #[default] Continuing, @@ -74,6 +87,86 @@ pub struct ArtistStatistics { impl Eq for ArtistStatistics {} +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct MetadataProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for MetadataProfile { + fn from(value: (&i64, &String)) -> Self { + MetadataProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: DownloadStatus, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub album_id: Option, + pub artist_id: Option, + #[serde(deserialize_with = "super::from_f64")] + pub size: f64, + #[serde(deserialize_with = "super::from_f64")] + pub sizeleft: f64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: Option, +} + +impl Eq for DownloadRecord {} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + strum::Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) @@ -83,6 +176,13 @@ impl From for Serdeable { serde_enum_from!( LidarrSerdeable { Artists(Vec), + DiskSpaces(Vec), + DownloadsResponse(DownloadsResponse), + MetadataProfiles(Vec), + QualityProfiles(Vec), + RootFolders(Vec), + SystemStatus(SystemStatus), + Tags(Vec), Value(Value), } ); diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 212ad87..70340f3 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -85,7 +85,6 @@ mod tests { let artist: Artist = serde_json::from_value(artist_json).unwrap(); assert_eq!(artist.id, 1); - assert_str_eq!(artist.mb_id, "test-mb-id"); assert_str_eq!(artist.artist_name.text, "Test Artist"); assert_str_eq!(artist.foreign_artist_id, "test-foreign-id"); assert_eq!(artist.status, ArtistStatus::Continuing); diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 185e9f1..2a311c3 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,20 +1,133 @@ +use bimap::BiMap; +use chrono::{DateTime, Utc}; use strum::EnumIter; #[cfg(test)] use strum::{Display, EnumString}; -use crate::models::Route; +use crate::models::{ + Route, TabRoute, TabState, + lidarr_models::{Artist, DownloadRecord}, + servarr_models::{DiskSpace, RootFolder}, + stateful_table::StatefulTable, +}; +use crate::network::lidarr_network::LidarrEvent; #[cfg(test)] #[path = "lidarr_data_tests.rs"] mod lidarr_data_tests; +pub struct LidarrData<'a> { + pub artists: StatefulTable, + pub disk_space_vec: Vec, + pub downloads: StatefulTable, + pub main_tabs: TabState, + pub metadata_profile_map: BiMap, + pub prompt_confirm: bool, + pub prompt_confirm_action: Option, + pub quality_profile_map: BiMap, + pub root_folders: StatefulTable, + pub selected_block: crate::models::BlockSelectionState<'a, ActiveLidarrBlock>, + pub start_time: DateTime, + pub tags_map: BiMap, + pub version: String, +} + +impl LidarrData<'_> { + pub fn reset_sorting(&mut self) { + self.artists.sorting(vec![]); + } +} + +impl<'a> Default for LidarrData<'a> { + fn default() -> LidarrData<'a> { + LidarrData { + artists: StatefulTable::default(), + disk_space_vec: Vec::new(), + downloads: StatefulTable::default(), + metadata_profile_map: BiMap::new(), + prompt_confirm: false, + prompt_confirm_action: None, + quality_profile_map: BiMap::new(), + root_folders: StatefulTable::default(), + selected_block: crate::models::BlockSelectionState::default(), + start_time: Utc::now(), + tags_map: BiMap::new(), + version: String::new(), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library".to_string(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: Some(&ARTISTS_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[cfg(test)] +impl LidarrData<'_> { + pub fn test_default_fully_populated() -> Self { + use crate::models::lidarr_models::{Artist, DownloadRecord}; + use crate::models::servarr_models::{DiskSpace, RootFolder}; + use crate::models::stateful_table::SortOption; + + let mut lidarr_data = LidarrData::default(); + lidarr_data.artists.set_items(vec![Artist::default()]); + lidarr_data.artists.sorting(vec![SortOption { + name: "Name", + cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)), + }]); + lidarr_data.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]); + lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); + lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]); + lidarr_data.disk_space_vec = vec![DiskSpace { + free_space: 50000000000, + total_space: 100000000000, + }]; + lidarr_data.downloads.set_items(vec![DownloadRecord::default()]); + lidarr_data.root_folders.set_items(vec![RootFolder::default()]); + lidarr_data.version = "1.0.0".to_owned(); + + lidarr_data + } +} + +use crate::app::context_clues::ContextClue; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; + +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 5] = [ + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] #[cfg_attr(test, derive(Display, EnumString))] pub enum ActiveLidarrBlock { #[default] Artists, + ArtistsSortPrompt, + SearchArtists, + SearchArtistsError, + FilterArtists, + FilterArtistsError, } +pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [ + ActiveLidarrBlock::Artists, + ActiveLidarrBlock::ArtistsSortPrompt, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::FilterArtists, + ActiveLidarrBlock::FilterArtistsError, +]; + impl From for Route { fn from(active_lidarr_block: ActiveLidarrBlock) -> Route { Route::Lidarr(active_lidarr_block, None) diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index a029570..3648819 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -174,9 +174,25 @@ where } pub fn set_filtered_items(&mut self, filtered_items: Vec) { + let items_len = filtered_items.len(); self.filtered_items = Some(filtered_items); + + let preserved_selection = self + .filtered_state + .as_ref() + .and_then(|state| state.selected()) + .map_or(0, |i| { + if i > 0 && i < items_len { + i + } else if i >= items_len && items_len > 0 { + items_len - 1 + } else { + 0 + } + }); + let mut filtered_state: TableState = Default::default(); - filtered_state.select(Some(0)); + filtered_state.select(Some(preserved_selection)); self.filtered_state = Some(filtered_state); } diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index 4cb823e..5e0311d 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -390,6 +390,47 @@ mod tests { assert_some_eq_x!(&filtered_stateful_table.filtered_items, &filtered_items_vec); } + #[test] + fn test_stateful_table_set_filtered_items_preserves_selection() { + let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"]; + let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default(); + + filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); + filtered_stateful_table + .filtered_state + .as_mut() + .unwrap() + .select(Some(1)); + + filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 1 + ); + + filtered_stateful_table + .filtered_state + .as_mut() + .unwrap() + .select(Some(5)); + + filtered_stateful_table.set_filtered_items(filtered_items_vec); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 2 + ); + } + #[test] fn test_stateful_table_current_selection() { let mut stateful_table = create_test_stateful_table(); diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs new file mode 100644 index 0000000..5f88749 --- /dev/null +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -0,0 +1,44 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{Artist, LidarrSerdeable}; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_list_artists_event() { + let artists_json = json!([{ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }]); + let response: Vec = serde_json::from_value(artists_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(artists_json) + .build_for(LidarrEvent::ListArtists) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await; + + mock.assert_async().await; + + let LidarrSerdeable::Artists(artists) = result.unwrap() else { + panic!("Expected Artists"); + }; + + assert_eq!(artists, response); + assert!(!app.lock().await.data.lidarr_data.artists.is_empty()); + } +} diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs new file mode 100644 index 0000000..77b8a80 --- /dev/null +++ b/src/network/lidarr_network/library/mod.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use log::info; + +use crate::models::lidarr_models::Artist; +use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::Route; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_library_network_tests.rs"] +mod lidarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn list_artists(&mut self) -> Result> { + info!("Fetching Lidarr artists"); + let event = LidarrEvent::ListArtists; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut artists_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Lidarr(ActiveLidarrBlock::ArtistsSortPrompt, _) + ) { + artists_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.lidarr_data.artists.set_items(artists_vec); + app.data.lidarr_data.artists.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index c56fac5..53371ac 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -1,13 +1,17 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{Artist, LidarrSerdeable}; - use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; - use pretty_assertions::{assert_eq, assert_str_eq}; + use pretty_assertions::assert_str_eq; use rstest::rstest; - use serde_json::json; #[rstest] + #[case(LidarrEvent::GetDiskSpace, "/diskspace")] + #[case(LidarrEvent::GetDownloads(500), "/queue")] + #[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")] + #[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(LidarrEvent::GetRootFolders, "/rootfolder")] + #[case(LidarrEvent::GetStatus, "/system/status")] + #[case(LidarrEvent::GetTags, "/tag")] #[case(LidarrEvent::HealthCheck, "/health")] #[case(LidarrEvent::ListArtists, "/artist")] fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { @@ -21,52 +25,4 @@ mod tests { NetworkEvent::from(LidarrEvent::HealthCheck) ); } - - #[tokio::test] - async fn test_handle_get_lidarr_healthcheck_event() { - let (mock, app, _server) = MockServarrApi::get() - .build_for(LidarrEvent::HealthCheck) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; - - mock.assert_async().await; - } - - #[tokio::test] - async fn test_handle_list_artists_event() { - let artists_json = json!([{ - "id": 1, - "mbId": "test-mb-id", - "artistName": "Test Artist", - "foreignArtistId": "test-foreign-id", - "status": "continuing", - "path": "/music/test-artist", - "qualityProfileId": 1, - "metadataProfileId": 1, - "monitored": true, - "genres": [], - "tags": [], - "added": "2023-01-01T00:00:00Z" - }]); - let response: Vec = serde_json::from_value(artists_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(artists_json) - .build_for(LidarrEvent::ListArtists) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await; - - mock.assert_async().await; - - let LidarrSerdeable::Artists(artists) = result.unwrap() else { - panic!("Expected Artists"); - }; - - assert_eq!(artists, response); - } } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 74c32a1..86981d7 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use log::info; -use super::{Network, NetworkEvent, NetworkResource}; -use crate::{ - models::lidarr_models::{Artist, LidarrSerdeable}, - network::RequestMethod, -}; +use super::{NetworkEvent, NetworkResource}; +use crate::models::lidarr_models::LidarrSerdeable; +use crate::network::Network; + +mod library; +mod system; #[cfg(test)] #[path = "lidarr_network_tests.rs"] @@ -13,6 +13,13 @@ mod lidarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { + GetDiskSpace, + GetDownloads(u64), + GetMetadataProfiles, + GetQualityProfiles, + GetRootFolders, + GetStatus, + GetTags, HealthCheck, ListArtists, } @@ -20,6 +27,13 @@ pub enum LidarrEvent { impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { + LidarrEvent::GetDiskSpace => "/diskspace", + LidarrEvent::GetDownloads(_) => "/queue", + LidarrEvent::GetMetadataProfiles => "/metadataprofile", + LidarrEvent::GetQualityProfiles => "/qualityprofile", + LidarrEvent::GetRootFolders => "/rootfolder", + LidarrEvent::GetStatus => "/system/status", + LidarrEvent::GetTags => "/tag", LidarrEvent::HealthCheck => "/health", LidarrEvent::ListArtists => "/artist", } @@ -38,6 +52,31 @@ impl Network<'_, '_> { lidarr_event: LidarrEvent, ) -> Result { match lidarr_event { + LidarrEvent::GetDiskSpace => self + .get_lidarr_diskspace() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetDownloads(count) => self + .get_lidarr_downloads(count) + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetMetadataProfiles => self + .get_lidarr_metadata_profiles() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetQualityProfiles => self + .get_lidarr_quality_profiles() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetRootFolders => self + .get_lidarr_root_folders() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetStatus => self + .get_lidarr_status() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from), LidarrEvent::HealthCheck => self .get_lidarr_healthcheck() .await @@ -45,30 +84,4 @@ impl Network<'_, '_> { LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), } } - - async fn get_lidarr_healthcheck(&mut self) -> Result<()> { - info!("Performing Lidarr health check"); - let event = LidarrEvent::HealthCheck; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn list_artists(&mut self) -> Result> { - info!("Fetching Lidarr artists"); - let event = LidarrEvent::ListArtists; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |_, _| ()) - .await - } } diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs new file mode 100644 index 0000000..4695fc9 --- /dev/null +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -0,0 +1,246 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{ + DownloadsResponse, LidarrSerdeable, MetadataProfile, SystemStatus, + }; + use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_lidarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(LidarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event() { + let metadata_profiles_json = json!([{ + "id": 1, + "name": "Standard" + }]); + let response: Vec = + serde_json::from_value(metadata_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(metadata_profiles_json) + .build_for(LidarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_eq!(metadata_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .metadata_profile_map + .get_by_left(&1), + Some(&"Standard".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event() { + let quality_profiles_json = json!([{ + "id": 1, + "name": "Lossless" + }]); + let response: Vec = + serde_json::from_value(quality_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(quality_profiles_json) + .build_for(LidarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_eq!(quality_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .quality_profile_map + .get_by_left(&1), + Some(&"Lossless".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([{ + "id": 1, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(tags_json) + .build_for(LidarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetTags).await; + + mock.assert_async().await; + + let LidarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_eq!(tags, response); + assert_eq!( + app.lock().await.data.lidarr_data.tags_map.get_by_left(&1), + Some(&"usenet".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_diskspace_event() { + let diskspace_json = json!([{ + "freeSpace": 50000000000i64, + "totalSpace": 100000000000i64 + }]); + let response: Vec = serde_json::from_value(diskspace_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(diskspace_json) + .build_for(LidarrEvent::GetDiskSpace) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetDiskSpace).await; + + mock.assert_async().await; + + let LidarrSerdeable::DiskSpaces(disk_spaces) = result.unwrap() else { + panic!("Expected DiskSpaces"); + }; + + assert_eq!(disk_spaces, response); + assert!(!app.lock().await.data.lidarr_data.disk_space_vec.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_downloads_event() { + let downloads_json = json!({ + "records": [{ + "title": "Test Album", + "status": "downloading", + "id": 1, + "size": 100.0, + "sizeleft": 50.0, + "indexer": "test-indexer" + }] + }); + let response: DownloadsResponse = serde_json::from_value(downloads_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(downloads_json) + .query("pageSize=500") + .build_for(LidarrEvent::GetDownloads(500)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetDownloads(500)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::DownloadsResponse(downloads_response) = result.unwrap() else { + panic!("Expected DownloadsResponse"); + }; + + assert_eq!(downloads_response, response); + assert!(!app.lock().await.data.lidarr_data.downloads.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_root_folders_event() { + let root_folders_json = json!([{ + "id": 1, + "path": "/music", + "accessible": true, + "freeSpace": 50000000000i64 + }]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(LidarrEvent::GetRootFolders) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders"); + }; + + assert_eq!(root_folders, response); + assert!(!app.lock().await.data.lidarr_data.root_folders.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_status_event() { + let status_json = json!({ + "version": "1.0.0", + "startTime": "2023-01-01T00:00:00Z" + }); + let response: SystemStatus = serde_json::from_value(status_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(status_json) + .build_for(LidarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetStatus).await; + + mock.assert_async().await; + + let LidarrSerdeable::SystemStatus(status) = result.unwrap() else { + panic!("Expected SystemStatus"); + }; + + assert_eq!(status, response); + assert_eq!(app.lock().await.data.lidarr_data.version, "1.0.0"); + } +} diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs new file mode 100644 index 0000000..8cbedf2 --- /dev/null +++ b/src/network/lidarr_network/system/mod.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use log::info; + +use crate::models::lidarr_models::{DownloadsResponse, MetadataProfile, SystemStatus}; +use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_system_network_tests.rs"] +mod lidarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn get_lidarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Lidarr health check"); + let event = LidarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_metadata_profiles( + &mut self, + ) -> Result> { + info!("Fetching Lidarr metadata profiles"); + let event = LidarrEvent::GetMetadataProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { + app.data.lidarr_data.metadata_profile_map = metadata_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_quality_profiles( + &mut self, + ) -> Result> { + info!("Fetching Lidarr quality profiles"); + let event = LidarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.lidarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_tags(&mut self) -> Result> { + info!("Fetching Lidarr tags"); + let event = LidarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.lidarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Lidarr disk space"); + let event = LidarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.lidarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Lidarr downloads"); + let event = LidarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .lidarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Lidarr root folders"); + let event = LidarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.lidarr_data.root_folders.set_items(root_folders); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_status( + &mut self, + ) -> Result { + info!("Fetching Lidarr system status"); + let event = LidarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.lidarr_data.version = system_status.version; + app.data.lidarr_data.start_time = system_status.start_time; + }) + .await + } +} diff --git a/src/network/sonarr_network/library/series/mod.rs b/src/network/sonarr_network/library/series/mod.rs index 100a162..3c532c8 100644 --- a/src/network/sonarr_network/library/series/mod.rs +++ b/src/network/sonarr_network/library/series/mod.rs @@ -20,7 +20,7 @@ impl Network<'_, '_> { pub(in crate::network::sonarr_network) async fn add_sonarr_series( &mut self, mut add_series_body: AddSeriesBody, - ) -> anyhow::Result { + ) -> Result { info!("Adding new series to Sonarr"); let event = SonarrEvent::AddSeries(AddSeriesBody::default()); if let Some(tag_input_str) = add_series_body.tag_input_string.as_ref() { diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs new file mode 100644 index 0000000..92bf3ce --- /dev/null +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -0,0 +1,20 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + use crate::models::Route; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::LibraryUi; + + #[test] + fn test_library_ui_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + if LIBRARY_BLOCKS.contains(&lidarr_block) { + assert!(LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + } else { + assert!(!LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + } + } + } +} diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs new file mode 100644 index 0000000..a54c803 --- /dev/null +++ b/src/ui/lidarr_ui/library/mod.rs @@ -0,0 +1,185 @@ +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, Row}, +}; + +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::utils::convert_to_gb; +use crate::{ + app::App, + models::{ + Route, + lidarr_models::{Artist, ArtistStatus}, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}, + }, + ui::{ + DrawUi, + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + }, +}; + +#[cfg(test)] +#[path = "library_ui_tests.rs"] +mod library_ui_tests; + +pub(super) struct LibraryUi; + +impl DrawUi for LibraryUi { + fn accepts(route: Route) -> bool { + if let Route::Lidarr(active_lidarr_block, _) = route { + return LIBRARY_BLOCKS.contains(&active_lidarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_library(f, app, area); + } +} + +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.lidarr_data.artists.items.is_empty() { + app.data.lidarr_data.artists.current_selection().clone() + } else { + Artist::default() + }; + let quality_profile_map = &app.data.lidarr_data.quality_profile_map; + let metadata_profile_map = &app.data.lidarr_data.metadata_profile_map; + let tags_map = &app.data.lidarr_data.tags_map; + let content = Some(&mut app.data.lidarr_data.artists); + + let artists_table_row_mapping = |artist: &Artist| { + artist.artist_name.scroll_left_or_reset( + get_width_from_percentage(area, 25), + *artist == current_selection, + app.ui_scroll_tick_count == 0, + ); + let monitored = if artist.monitored { "🏷" } else { "" }; + let artist_type = artist.artist_type.clone().unwrap_or_default(); + let size = artist + .statistics + .as_ref() + .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + let quality_profile = quality_profile_map + .get_by_left(&artist.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = metadata_profile_map + .get_by_left(&artist.metadata_profile_id) + .cloned() + .unwrap_or_default(); + let albums = artist + .statistics + .as_ref() + .map_or(0, |stats| stats.album_count); + let tracks = artist + .statistics + .as_ref() + .map_or(String::new(), |stats| { + format!("{}/{}", stats.track_file_count, stats.total_track_count) + }); + let tags = artist + .tags + .iter() + .filter_map(|tag_id| { + let id = tag_id.as_i64()?; + tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", "); + + decorate_artist_row_with_style( + artist, + Row::new(vec![ + Cell::from(artist.artist_name.to_string()), + Cell::from(artist_type), + Cell::from(artist.status.to_display_str()), + Cell::from(quality_profile), + Cell::from(metadata_profile), + Cell::from(albums.to_string()), + Cell::from(tracks), + Cell::from(format!("{size:.2} GB")), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let artists_table = ManagarrTable::new(content, artists_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_lidarr_block == ActiveLidarrBlock::ArtistsSortPrompt) + .searching(active_lidarr_block == ActiveLidarrBlock::SearchArtists) + .filtering(active_lidarr_block == ActiveLidarrBlock::FilterArtists) + .search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchArtistsError) + .filter_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::FilterArtistsError) + .headers([ + "Name", + "Type", + "Status", + "Quality Profile", + "Metadata Profile", + "Albums", + "Tracks", + "Size", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(22), + Constraint::Percentage(8), + Constraint::Percentage(8), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(6), + Constraint::Percentage(8), + Constraint::Percentage(7), + Constraint::Percentage(6), + Constraint::Percentage(11), + ]); + + if [ + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::FilterArtists, + ] + .contains(&active_lidarr_block) + { + artists_table.show_cursor(f, area); + } + + f.render_widget(artists_table, area); + } +} + +fn decorate_artist_row_with_style<'a>(artist: &Artist, row: Row<'a>) -> Row<'a> { + if !artist.monitored { + return row.unmonitored(); + } + + match artist.status { + ArtistStatus::Ended => { + if let Some(ref stats) = artist.statistics { + return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.downloaded() + } else { + row.missing() + }; + } + row.indeterminate() + } + ArtistStatus::Continuing => { + if let Some(ref stats) = artist.statistics { + return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.unreleased() + } else { + row.missing() + }; + } + row.indeterminate() + } + _ => row.indeterminate(), + } +} diff --git a/src/ui/lidarr_ui/lidarr_ui_tests.rs b/src/ui/lidarr_ui/lidarr_ui_tests.rs new file mode 100644 index 0000000..d29e8d1 --- /dev/null +++ b/src/ui/lidarr_ui/lidarr_ui_tests.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::Route; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::LidarrUi; + + #[test] + fn test_lidarr_ui_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + assert!(LidarrUi::accepts(Route::Lidarr(lidarr_block, None))); + } + } +} diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs new file mode 100644 index 0000000..faec50f --- /dev/null +++ b/src/ui/lidarr_ui/mod.rs @@ -0,0 +1,209 @@ +use std::{cmp, iter}; + +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use chrono::Duration; +#[cfg(not(test))] +use chrono::Utc; +use library::LibraryUi; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, +}; + +use crate::{ + app::App, + logos::LIDARR_LOGO, + models::{ + Route, + lidarr_models::DownloadRecord, + servarr_data::lidarr::lidarr_data::LidarrData, + servarr_models::{DiskSpace, RootFolder}, + }, + utils::convert_to_gb, +}; + +use super::{ + DrawUi, draw_tabs, + styles::ManagarrStyle, + utils::{borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block}, + widgets::loading_block::LoadingBlock, +}; + +mod library; + +#[cfg(test)] +#[path = "lidarr_ui_tests.rs"] +mod lidarr_ui_tests; + +pub(super) struct LidarrUi; + +impl DrawUi for LidarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Lidarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Artists", &app.data.lidarr_data.main_tabs); + let route = app.get_current_route(); + + match route { + _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ => (), + } + } + + fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let [main_area, logo_area] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area); + + let [stats_area, downloads_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area); + + draw_stats_context(f, app, stats_area); + draw_downloads_context(f, app, downloads_area); + draw_lidarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.lidarr_data.version.is_empty() { + f.render_widget(block, area); + let LidarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.lidarr_data; + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat_n( + Constraint::Length(1), + disk_space_vec.len() + root_folders.items.len() + 1, + ) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Lidarr Version: {}", + app.data.lidarr_data.version + ))) + .block(borderless_block()) + .bold(); + + let uptime = Utc::now() - start_time.to_owned(); + let days = uptime.num_days(); + let day_difference = uptime - Duration::days(days); + let hours = day_difference.num_hours(); + let hour_difference = day_difference - Duration::hours(hours); + let minutes = hour_difference.num_minutes(); + let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}", + width = 2 + ))) + .block(borderless_block()) + .bold(); + + let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold()); + let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold()); + + f.render_widget(version_paragraph, stat_item_areas[0]); + f.render_widget(uptime_paragraph, stat_item_areas[1]); + f.render_widget(storage, stat_item_areas[2]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if *total_space == 0 { + 0f64 + } else { + 1f64 - (*free_space as f64 / *total_space as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, stat_item_areas[i + 3]); + } + + f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + + for i in 0..root_folders.items.len() { + let RootFolder { + path, free_space, .. + } = &root_folders.items[i]; + let space: f64 = convert_to_gb(*free_space); + let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + .block(borderless_block()) + .default(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + disk_space_vec.len() + 4], + ) + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.lidarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + + let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); + let download_item_areas = + Layout::vertical(iter::repeat_n(Constraint::Length(2), items).collect::>()) + .margin(1) + .split(area); + + for i in 0..items { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let download_gauge = line_gauge_with_title(title, percent); + + f.render_widget(download_gauge, download_item_areas[i]); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_lidarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(LIDARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_green() + .block(layout_block().default()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d974eee..71e6837 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,6 +9,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::widgets::{Clear, Row}; +use lidarr_ui::LidarrUi; use sonarr_ui::SonarrUi; use utils::layout_block; @@ -27,6 +28,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; mod builtin_themes; +mod lidarr_ui; mod radarr_ui; mod sonarr_ui; mod styles; @@ -86,6 +88,10 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { SonarrUi::draw_context_row(f, app, context_area); SonarrUi::draw(f, app, table_area); } + route if LidarrUi::accepts(route) => { + LidarrUi::draw_context_row(f, app, context_area); + LidarrUi::draw(f, app, table_area); + } _ => (), }