Lidarr support #1

Merged
Dark-Alex-17 merged 61 commits from lidarr into main 2026-01-21 21:30:47 +00:00
29 changed files with 2113 additions and 91 deletions
Showing only changes of commit bc3aeefa6e - Show all commits
+2
View File
@@ -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,
};
+2
View File
@@ -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,
}
}
+25
View File
@@ -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(),
}
}
}
@@ -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);
}
}
+28
View File
@@ -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::<NetworkEvent>(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());
}
}
+97
View File
@@ -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;
}
}
+5 -1
View File
@@ -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>,
}
@@ -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::<Vec<String>>()
.join(",");
let b_str = b
.tags
.iter()
.map(|tag| tag.as_i64().unwrap().to_string())
.collect::<Vec<String>>()
.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<Artist> {
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()
},
]
}
}
+211
View File
@@ -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<ActiveLidarrBlock>,
}
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<ActiveLidarrBlock>,
) -> 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<SortOption<Artist>> {
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::<Vec<String>>()
.join(",");
let b_str = b
.tags
.iter()
.map(|tag| tag.as_i64().unwrap().to_string())
.collect::<Vec<String>>()
.join(",");
a_str.cmp(&b_str)
}),
},
]
}
@@ -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));
}
}
}
+102
View File
@@ -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<ActiveLidarrBlock>,
}
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<ActiveLidarrBlock>,
) -> 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());
}
_ => (),
}
}
+8
View File
@@ -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
}
_ => (),
},
_ => (),
+102 -2
View File
@@ -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<ArtistStatistics>,
}
#[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<Number>,
pub artist_id: Option<Number>,
#[serde(deserialize_with = "super::from_f64")]
pub size: f64,
#[serde(deserialize_with = "super::from_f64")]
pub sizeleft: f64,
pub output_path: Option<HorizontallyScrollableText>,
#[serde(default)]
pub indexer: String,
pub download_client: Option<String>,
}
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<DownloadRecord>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
pub version: String,
pub start_time: DateTime<Utc>,
}
impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
@@ -83,6 +176,13 @@ impl From<LidarrSerdeable> for Serdeable {
serde_enum_from!(
LidarrSerdeable {
Artists(Vec<Artist>),
DiskSpaces(Vec<super::servarr_models::DiskSpace>),
DownloadsResponse(DownloadsResponse),
MetadataProfiles(Vec<MetadataProfile>),
QualityProfiles(Vec<super::servarr_models::QualityProfile>),
RootFolders(Vec<super::servarr_models::RootFolder>),
SystemStatus(SystemStatus),
Tags(Vec<super::servarr_models::Tag>),
Value(Value),
}
);
-1
View File
@@ -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);
+114 -1
View File
@@ -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<Artist>,
pub disk_space_vec: Vec<DiskSpace>,
pub downloads: StatefulTable<DownloadRecord>,
pub main_tabs: TabState,
pub metadata_profile_map: BiMap<i64, String>,
pub prompt_confirm: bool,
pub prompt_confirm_action: Option<LidarrEvent>,
pub quality_profile_map: BiMap<i64, String>,
pub root_folders: StatefulTable<RootFolder>,
pub selected_block: crate::models::BlockSelectionState<'a, ActiveLidarrBlock>,
pub start_time: DateTime<Utc>,
pub tags_map: BiMap<i64, String>,
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<ActiveLidarrBlock> for Route {
fn from(active_lidarr_block: ActiveLidarrBlock) -> Route {
Route::Lidarr(active_lidarr_block, None)
+17 -1
View File
@@ -174,9 +174,25 @@ where
}
pub fn set_filtered_items(&mut self, filtered_items: Vec<T>) {
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);
}
+41
View File
@@ -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();
@@ -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<Artist> = 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());
}
}
+36
View File
@@ -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<Vec<Artist>> {
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<Artist>>(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
}
}
@@ -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<Artist> = 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);
}
}
+45 -32
View File
@@ -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<LidarrSerdeable> {
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<Vec<Artist>> {
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<Artist>>(request_props, |_, _| ())
.await
}
}
@@ -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<MetadataProfile> =
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<QualityProfile> =
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<Tag> = 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<DiskSpace> = 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<RootFolder> = 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");
}
}
+164
View File
@@ -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<Vec<MetadataProfile>> {
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<MetadataProfile>>(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<Vec<QualityProfile>> {
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<QualityProfile>>(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<Vec<Tag>> {
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<Tag>>(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<Vec<DiskSpace>> {
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<DiskSpace>>(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<DownloadsResponse> {
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<Vec<RootFolder>> {
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<RootFolder>>(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<SystemStatus> {
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
}
}
@@ -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<Value> {
) -> Result<Value> {
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() {
@@ -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)));
}
}
}
}
+185
View File
@@ -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::<Vec<_>>()
.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(),
}
}
+16
View File
@@ -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)));
}
}
}
+209
View File
@@ -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::<Vec<Constraint>>())
.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);
}
+6
View File
@@ -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);
}
_ => (),
}