feat: Implemented the Lidarr History tab and CLI support

This commit is contained in:
2026-01-12 14:21:58 -07:00
parent f31810e48a
commit 68b08d1cd7
41 changed files with 2505 additions and 78 deletions
@@ -0,0 +1,394 @@
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use chrono::DateTime;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::assert_navigation_pushed;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::history::{HistoryHandler, history_sorting_options};
use crate::models::lidarr_models::{LidarrHistoryEventType, LidarrHistoryItem};
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS};
use crate::models::servarr_models::{Quality, QualityWrapper};
mod test_handle_left_right_action {
use pretty_assertions::assert_eq;
use rstest::rstest;
use super::*;
use crate::assert_navigation_pushed;
#[rstest]
fn test_history_tab_left(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(1);
HistoryHandler::new(
DEFAULT_KEYBINDINGS.left.key,
&mut app,
ActiveLidarrBlock::History,
None,
)
.handle();
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::Artists.into()
);
assert_navigation_pushed!(app, ActiveLidarrBlock::Artists.into());
}
#[rstest]
fn test_history_tab_right(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(1);
HistoryHandler::new(
DEFAULT_KEYBINDINGS.right.key,
&mut app,
ActiveLidarrBlock::History,
None,
)
.handle();
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::Artists.into()
);
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
}
mod test_handle_submit {
use pretty_assertions::assert_eq;
use super::*;
const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key;
#[test]
fn test_history_submit() {
let mut app = App::test_default();
app.data.lidarr_data.history.set_items(history_vec());
app.push_navigation_stack(ActiveLidarrBlock::History.into());
HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::History, None).handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::HistoryItemDetails.into());
}
#[test]
fn test_history_submit_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.data.lidarr_data.history.set_items(history_vec());
app.push_navigation_stack(ActiveLidarrBlock::History.into());
HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::History, None).handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into());
}
}
mod test_handle_esc {
use pretty_assertions::assert_eq;
use rstest::rstest;
use super::*;
use crate::assert_navigation_popped;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[test]
fn test_esc_history_item_details() {
let mut app = App::test_default();
app
.data
.lidarr_data
.history
.set_items(vec![LidarrHistoryItem::default()]);
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.push_navigation_stack(ActiveLidarrBlock::HistoryItemDetails.into());
HistoryHandler::new(
ESC_KEY,
&mut app,
ActiveLidarrBlock::HistoryItemDetails,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::History.into());
}
#[rstest]
fn test_default_esc(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.is_loading = is_ready;
app.error = "test error".to_owned().into();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app
.data
.lidarr_data
.history
.set_items(vec![LidarrHistoryItem::default()]);
HistoryHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::History, None).handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into());
assert_is_empty!(app.error.text);
}
}
mod test_handle_key_char {
use pretty_assertions::assert_eq;
use super::*;
use crate::assert_navigation_pushed;
#[test]
fn test_refresh_history_key() {
let mut app = App::test_default();
app.data.lidarr_data.history.set_items(history_vec());
app.push_navigation_stack(ActiveLidarrBlock::History.into());
HistoryHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveLidarrBlock::History,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::History.into());
assert!(app.should_refresh);
}
#[test]
fn test_refresh_history_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.data.lidarr_data.history.set_items(history_vec());
app.push_navigation_stack(ActiveLidarrBlock::History.into());
HistoryHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveLidarrBlock::History,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into());
assert!(!app.should_refresh);
}
}
#[test]
fn test_history_sorting_options_source_title() {
let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
};
let mut expected_history_vec = history_vec();
expected_history_vec.sort_by(expected_cmp_fn);
let sort_option = history_sorting_options()[0].clone();
let mut sorted_history_vec = history_vec();
sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_history_vec, expected_history_vec);
assert_str_eq!(sort_option.name, "Source Title");
}
#[test]
fn test_history_sorting_options_event_type() {
let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| {
a.event_type
.to_string()
.to_lowercase()
.cmp(&b.event_type.to_string().to_lowercase())
};
let mut expected_history_vec = history_vec();
expected_history_vec.sort_by(expected_cmp_fn);
let sort_option = history_sorting_options()[1].clone();
let mut sorted_history_vec = history_vec();
sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_history_vec, expected_history_vec);
assert_str_eq!(sort_option.name, "Event Type");
}
#[test]
fn test_history_sorting_options_quality() {
let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering = |a, b| {
a.quality
.quality
.name
.to_lowercase()
.cmp(&b.quality.quality.name.to_lowercase())
};
let mut expected_history_vec = history_vec();
expected_history_vec.sort_by(expected_cmp_fn);
let sort_option = history_sorting_options()[2].clone();
let mut sorted_history_vec = history_vec();
sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_history_vec, expected_history_vec);
assert_str_eq!(sort_option.name, "Quality");
}
#[test]
fn test_history_sorting_options_date() {
let expected_cmp_fn: fn(&LidarrHistoryItem, &LidarrHistoryItem) -> Ordering =
|a, b| a.date.cmp(&b.date);
let mut expected_history_vec = history_vec();
expected_history_vec.sort_by(expected_cmp_fn);
let sort_option = history_sorting_options()[3].clone();
let mut sorted_history_vec = history_vec();
sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_history_vec, expected_history_vec);
assert_str_eq!(sort_option.name, "Date");
}
#[test]
fn test_history_handler_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if HISTORY_BLOCKS.contains(&active_lidarr_block) {
assert!(HistoryHandler::accepts(active_lidarr_block));
} else {
assert!(!HistoryHandler::accepts(active_lidarr_block));
}
})
}
#[rstest]
fn test_history_handler_ignore_special_keys(
#[values(true, false)] ignore_special_keys_for_textbox_input: bool,
) {
let mut app = App::test_default();
app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input;
let handler = HistoryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_history_handler_not_ready_when_loading() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = true;
let handler = HistoryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::History,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_history_handler_not_ready_when_history_is_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = false;
let handler = HistoryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::History,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = false;
app
.data
.lidarr_data
.history
.set_items(vec![LidarrHistoryItem::default()]);
let handler = HistoryHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::History,
None,
);
assert!(handler.is_ready());
}
fn history_vec() -> Vec<LidarrHistoryItem> {
vec![
LidarrHistoryItem {
id: 3,
source_title: "test 1".into(),
event_type: LidarrHistoryEventType::Grabbed,
quality: QualityWrapper {
quality: Quality {
name: "FLAC".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()),
..LidarrHistoryItem::default()
},
LidarrHistoryItem {
id: 2,
source_title: "test 2".into(),
event_type: LidarrHistoryEventType::DownloadImported,
quality: QualityWrapper {
quality: Quality {
name: "MP3-320".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()),
..LidarrHistoryItem::default()
},
LidarrHistoryItem {
id: 1,
source_title: "test 3".into(),
event_type: LidarrHistoryEventType::TrackFileDeleted,
quality: QualityWrapper {
quality: Quality {
name: "FLAC".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()),
..LidarrHistoryItem::default()
},
]
}
}
+165
View File
@@ -0,0 +1,165 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::lidarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors};
use crate::matches_key;
use crate::models::Route;
use crate::models::lidarr_models::LidarrHistoryItem;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS};
use crate::models::stateful_table::SortOption;
#[cfg(test)]
#[path = "history_handler_tests.rs"]
mod history_handler_tests;
pub(super) struct HistoryHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl HistoryHandler<'_, '_> {}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for HistoryHandler<'a, 'b> {
fn handle(&mut self) {
let history_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::History.into())
.sorting_block(ActiveLidarrBlock::HistorySortPrompt.into())
.sort_options(history_sorting_options())
.searching_block(ActiveLidarrBlock::SearchHistory.into())
.search_error_block(ActiveLidarrBlock::SearchHistoryError.into())
.search_field_fn(|history| &history.source_title.text)
.filtering_block(ActiveLidarrBlock::FilterHistory.into())
.filter_error_block(ActiveLidarrBlock::FilterHistoryError.into())
.filter_field_fn(|history| &history.source_title.text);
if !handle_table(
self,
|app| &mut app.data.lidarr_data.history,
history_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
HISTORY_BLOCKS.contains(&active_block)
}
fn new(
key: Key,
app: &'a mut App<'b>,
active_block: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
) -> Self {
HistoryHandler {
key,
app,
active_lidarr_block: active_block,
_context: context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn ignore_special_keys(&self) -> bool {
self.app.ignore_special_keys_for_textbox_input
}
fn is_ready(&self) -> bool {
!self.app.is_loading && !self.app.data.lidarr_data.history.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::History {
handle_change_tab_left_right_keys(self.app, self.key)
}
}
fn handle_submit(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::History {
self
.app
.push_navigation_stack(ActiveLidarrBlock::HistoryItemDetails.into());
}
}
fn handle_esc(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::HistoryItemDetails {
self.app.pop_navigation_stack();
} else {
handle_clear_errors(self.app);
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
if self.active_lidarr_block == ActiveLidarrBlock::History {
match self.key {
_ if matches_key!(refresh, key) => {
self.app.should_refresh = true;
}
_ => (),
}
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
pub(in crate::handlers::lidarr_handlers) fn history_sorting_options()
-> Vec<SortOption<LidarrHistoryItem>> {
vec![
SortOption {
name: "Source Title",
cmp_fn: Some(|a, b| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
}),
},
SortOption {
name: "Event Type",
cmp_fn: Some(|a, b| {
a.event_type
.to_string()
.to_lowercase()
.cmp(&b.event_type.to_string().to_lowercase())
}),
},
SortOption {
name: "Quality",
cmp_fn: Some(|a, b| {
a.quality
.quality
.name
.to_lowercase()
.cmp(&b.quality.quality.name.to_lowercase())
}),
},
SortOption {
name: "Date",
cmp_fn: Some(|a, b| a.date.cmp(&b.date)),
},
]
}
@@ -2,9 +2,11 @@
mod tests {
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::assert_navigation_pushed;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::LidarrHandler;
use crate::handlers::lidarr_handlers::{LidarrHandler, handle_change_tab_left_right_keys};
use crate::models::lidarr_models::Artist;
use crate::models::lidarr_models::LidarrHistoryItem;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use pretty_assertions::assert_eq;
@@ -52,6 +54,97 @@ mod tests {
}
}
#[rstest]
#[case(0, ActiveLidarrBlock::History, ActiveLidarrBlock::History)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Artists)]
fn test_lidarr_handler_change_tab_left_right_keys(
#[case] index: usize,
#[case] left_block: ActiveLidarrBlock,
#[case] right_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key);
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
left_block.into()
);
assert_navigation_pushed!(app, left_block.into());
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key);
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
right_block.into()
);
assert_navigation_pushed!(app, right_block.into());
}
#[rstest]
#[case(0, ActiveLidarrBlock::History, ActiveLidarrBlock::History)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Artists)]
fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation(
#[case] index: usize,
#[case] left_block: ActiveLidarrBlock,
#[case] right_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap());
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
left_block.into()
);
assert_navigation_pushed!(app, left_block.into());
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap());
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
right_block.into()
);
assert_navigation_pushed!(app, right_block.into());
}
#[rstest]
#[case(0, ActiveLidarrBlock::Artists)]
#[case(1, ActiveLidarrBlock::History)]
fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key(
#[case] index: usize,
#[case] block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(block.into());
app.ignore_special_keys_for_textbox_input = true;
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap());
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
block.into()
);
assert_eq!(app.get_current_route(), block.into());
app.data.lidarr_data.main_tabs.set_index(index);
handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap());
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
block.into()
);
assert_eq!(app.get_current_route(), block.into());
}
#[rstest]
fn test_delegates_library_blocks_to_library_handler(
#[values(
@@ -92,4 +185,37 @@ mod tests {
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
}
#[rstest]
fn test_delegates_history_blocks_to_history_handler(
#[values(
ActiveLidarrBlock::History,
ActiveLidarrBlock::HistoryItemDetails,
ActiveLidarrBlock::HistorySortPrompt,
ActiveLidarrBlock::FilterHistory,
ActiveLidarrBlock::FilterHistoryError,
ActiveLidarrBlock::SearchHistory,
ActiveLidarrBlock::SearchHistoryError
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app
.data
.lidarr_data
.history
.set_items(vec![LidarrHistoryItem::default()]);
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.push_navigation_stack(active_lidarr_block.into());
LidarrHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into());
}
}
+5
View File
@@ -1,3 +1,4 @@
use history::HistoryHandler;
use library::LibraryHandler;
use super::KeyEventHandler;
@@ -6,6 +7,7 @@ use crate::{
app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
};
mod history;
mod library;
#[cfg(test)]
@@ -25,6 +27,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b
_ if LibraryHandler::accepts(self.active_lidarr_block) => {
LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
_ if HistoryHandler::accepts(self.active_lidarr_block) => {
HistoryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
_ => self.handle_key_event(),
}
}