Lidarr support #1

Merged
Dark-Alex-17 merged 61 commits from lidarr into main 2026-01-21 21:30:47 +00:00
41 changed files with 2505 additions and 78 deletions
Showing only changes of commit 68b08d1cd7 - Show all commits
+12
View File
@@ -102,6 +102,18 @@ pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [
),
];
pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(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"),
];
pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.tasks, "open tasks"),
(DEFAULT_KEYBINDINGS.events, "open events"),
+35 -1
View File
@@ -2,7 +2,7 @@
mod test {
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
ServarrContextClueProvider,
};
@@ -204,6 +204,40 @@ mod test {
assert_none!(indexers_context_clues_iter.next());
}
#[test]
fn test_history_context_clues() {
let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(history_context_clues_iter.next());
}
#[test]
fn test_system_context_clues() {
let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter();
+19
View File
@@ -70,6 +70,25 @@ mod tests {
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
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::History)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetHistory(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_extract_add_new_artist_search_query() {
let app = App::test_default_fully_populated();
+5
View File
@@ -40,6 +40,11 @@ impl App<'_> {
)
.await;
}
ActiveLidarrBlock::History => {
self
.dispatch_network_event(LidarrEvent::GetHistory(500).into())
.await
}
_ => (),
}
-12
View File
@@ -57,18 +57,6 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(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"),
];
pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
+5 -40
View File
@@ -2,8 +2,8 @@
mod tests {
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
};
use crate::app::sonarr::sonarr_context_clues::{
SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES, SonarrContextClueProvider,
@@ -13,10 +13,9 @@ mod tests {
key_binding::DEFAULT_KEYBINDINGS,
sonarr::sonarr_context_clues::{
ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES,
HISTORY_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES,
MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES,
SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES,
SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES,
MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES,
SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES,
SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES,
},
};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
@@ -146,40 +145,6 @@ mod tests {
assert_none!(series_history_context_clues_iter.next());
}
#[test]
fn test_history_context_clues() {
let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(history_context_clues_iter.next());
}
#[test]
fn test_series_details_context_clues() {
let mut series_details_context_clues_iter = SERIES_DETAILS_CONTEXT_CLUES.iter();
+56
View File
@@ -123,6 +123,31 @@ mod tests {
assert_ok!(&result);
}
#[test]
fn test_mark_history_item_as_failed_requires_history_item_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "mark-history-item-as-failed"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_mark_history_item_as_failed_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"mark-history-item-as-failed",
"--history-item-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
@@ -364,5 +389,36 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1i64;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let mark_history_item_as_failed_command = LidarrCommand::MarkHistoryItemAsFailed {
history_item_id: expected_history_item_id,
};
let result = LidarrCliHandler::with(
&app_arc,
mark_history_item_as_failed_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
+12
View File
@@ -29,6 +29,11 @@ pub enum LidarrListCommand {
},
#[command(about = "List all artists in your Lidarr library")]
Artists,
#[command(about = "Fetch all Lidarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Lidarr metadata profiles")]
MetadataProfiles,
#[command(about = "List all Lidarr quality profiles")]
@@ -78,6 +83,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHistory(items).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::MetadataProfiles => {
let resp = self
.network
@@ -57,6 +57,29 @@ mod tests {
};
assert_eq!(album_command, expected_args);
}
#[test]
fn test_list_history_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "history", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_history_default_values() {
let expected_args = LidarrListCommand::History { events: 500 };
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "history"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(history_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(history_command, expected_args);
}
}
mod handler {
@@ -127,5 +150,31 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_history_command() {
let expected_events = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetHistory(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_history_command = LidarrListCommand::History { events: 1000 };
let result =
LidarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+17
View File
@@ -8,6 +8,7 @@ use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler};
use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler};
use serde_json::json;
use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
@@ -67,6 +68,15 @@ pub enum LidarrCommand {
about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance"
)]
TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand),
#[command(about = "Mark the Lidarr history item with the given ID as 'failed'")]
MarkHistoryItemAsFailed {
#[arg(
long,
help = "The Lidarr ID of the history item you wish to mark as 'failed'",
required = true
)]
history_item_id: i64,
},
#[command(about = "Search for a new artist to add to Lidarr")]
SearchNewArtist {
#[arg(
@@ -166,6 +176,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, '
.handle()
.await?
}
LidarrCommand::MarkHistoryItemAsFailed { history_item_id } => {
let _ = self
.network
.handle_network_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
serde_json::to_string_pretty(&json!({"message": "Lidarr history item marked as 'failed'"}))?
}
LidarrCommand::SearchNewArtist { query } => {
let resp = self
.network
+2 -1
View File
@@ -10,6 +10,7 @@ use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler};
use list_command_handler::{SonarrListCommand, SonarrListCommandHandler};
use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler};
use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler};
use serde_json::json;
use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler,
@@ -251,7 +252,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, '
.network
.handle_network_event(SonarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
"Sonarr history item marked as 'failed'".to_owned()
serde_json::to_string_pretty(&json!({"message": "Sonarr history item marked as 'failed'"}))?
}
SonarrCommand::SearchNewSeries { query } => {
let resp = self
@@ -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(),
}
}
+76 -1
View File
@@ -7,7 +7,9 @@ use strum::{Display, EnumIter};
use super::{
HorizontallyScrollableText, Serdeable,
servarr_models::{DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag},
servarr_models::{
DiskSpace, HostConfig, QualityProfile, QualityWrapper, RootFolder, SecurityConfig, Tag,
},
};
use crate::serde_enum_from;
@@ -337,6 +339,78 @@ pub struct AlbumStatistics {
impl Eq for AlbumStatistics {}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LidarrHistoryWrapper {
pub records: Vec<LidarrHistoryItem>,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LidarrHistoryData {
pub indexer: Option<String>,
pub release_group: Option<String>,
pub nzb_info_url: Option<String>,
pub download_client_name: Option<String>,
pub download_client: Option<String>,
pub age: Option<String>,
pub published_date: Option<DateTime<Utc>>,
pub message: Option<String>,
pub reason: Option<String>,
pub dropped_path: Option<String>,
pub imported_path: Option<String>,
pub source_path: Option<String>,
pub path: Option<String>,
pub status_messages: Option<String>,
}
#[derive(
Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Display, EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum LidarrHistoryEventType {
#[default]
Unknown,
Grabbed,
#[display_style(name = "Artist Folder Imported")]
ArtistFolderImported,
#[display_style(name = "Album Import Incomplete")]
AlbumImportIncomplete,
#[display_style(name = "Download Ignored")]
DownloadIgnored,
#[display_style(name = "Download Imported")]
DownloadImported,
#[display_style(name = "Download Failed")]
DownloadFailed,
#[display_style(name = "Track File Deleted")]
TrackFileDeleted,
#[display_style(name = "Track File Imported")]
TrackFileImported,
#[display_style(name = "Track File Renamed")]
TrackFileRenamed,
#[display_style(name = "Track File Retagged")]
TrackFileRetagged,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LidarrHistoryItem {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub source_title: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_i64")]
pub album_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub artist_id: i64,
#[serde(default)]
pub quality: QualityWrapper,
pub date: DateTime<Utc>,
pub event_type: LidarrHistoryEventType,
#[serde(default)]
pub data: LidarrHistoryData,
}
impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
@@ -352,6 +426,7 @@ serde_enum_from!(
Artists(Vec<Artist>),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
HistoryWrapper(LidarrHistoryWrapper),
HostConfig(HostConfig),
MetadataProfiles(Vec<MetadataProfile>),
QualityProfiles(Vec<QualityProfile>),
+104 -2
View File
@@ -5,8 +5,9 @@ mod tests {
use serde_json::json;
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse, Member,
MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus,
AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse,
LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, Member, MetadataProfile,
MonitorType, NewItemMonitorType, SystemStatus,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag,
@@ -302,6 +303,23 @@ mod tests {
);
}
#[test]
fn test_lidarr_serdeable_from_history_wrapper() {
let history_wrapper = LidarrHistoryWrapper {
records: vec![LidarrHistoryItem {
id: 1,
..LidarrHistoryItem::default()
}],
};
let lidarr_serdeable: LidarrSerdeable = history_wrapper.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::HistoryWrapper(history_wrapper)
);
}
#[test]
fn test_lidarr_serdeable_from_metadata_profiles() {
let metadata_profiles = vec![MetadataProfile {
@@ -488,6 +506,90 @@ mod tests {
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_lidarr_history_event_type_display() {
assert_str_eq!(LidarrHistoryEventType::Unknown.to_string(), "unknown");
assert_str_eq!(LidarrHistoryEventType::Grabbed.to_string(), "grabbed");
assert_str_eq!(
LidarrHistoryEventType::ArtistFolderImported.to_string(),
"artistFolderImported"
);
assert_str_eq!(
LidarrHistoryEventType::AlbumImportIncomplete.to_string(),
"albumImportIncomplete"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadIgnored.to_string(),
"downloadIgnored"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadImported.to_string(),
"downloadImported"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadFailed.to_string(),
"downloadFailed"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileDeleted.to_string(),
"trackFileDeleted"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileImported.to_string(),
"trackFileImported"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileRenamed.to_string(),
"trackFileRenamed"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileRetagged.to_string(),
"trackFileRetagged"
);
}
#[test]
fn test_lidarr_history_event_type_to_display_str() {
assert_str_eq!(LidarrHistoryEventType::Unknown.to_display_str(), "Unknown");
assert_str_eq!(LidarrHistoryEventType::Grabbed.to_display_str(), "Grabbed");
assert_str_eq!(
LidarrHistoryEventType::ArtistFolderImported.to_display_str(),
"Artist Folder Imported"
);
assert_str_eq!(
LidarrHistoryEventType::AlbumImportIncomplete.to_display_str(),
"Album Import Incomplete"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadIgnored.to_display_str(),
"Download Ignored"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadImported.to_display_str(),
"Download Imported"
);
assert_str_eq!(
LidarrHistoryEventType::DownloadFailed.to_display_str(),
"Download Failed"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileDeleted.to_display_str(),
"Track File Deleted"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileImported.to_display_str(),
"Track File Imported"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileRenamed.to_display_str(),
"Track File Renamed"
);
assert_str_eq!(
LidarrHistoryEventType::TrackFileRetagged.to_display_str(),
"Track File Retagged"
);
}
#[test]
fn test_add_artist_search_result_deserialization() {
let search_result_json = json!({
+44 -9
View File
@@ -1,12 +1,13 @@
use serde_json::Number;
use super::modals::{AddArtistModal, EditArtistModal};
use crate::app::context_clues::HISTORY_CONTEXT_CLUES;
use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
};
use crate::models::{
BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState,
lidarr_models::{AddArtistSearchResult, Album, Artist, DownloadRecord},
lidarr_models::{AddArtistSearchResult, Album, Artist, DownloadRecord, LidarrHistoryItem},
servarr_models::{DiskSpace, RootFolder},
stateful_table::StatefulTable,
};
@@ -21,8 +22,8 @@ use {
crate::models::stateful_table::SortOption,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
add_artist_search_result, album, artist, download_record, metadata_profile,
metadata_profile_map, quality_profile, root_folder, tags_map,
add_artist_search_result, album, artist, download_record, lidarr_history_item,
metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map,
},
crate::network::servarr_test_utils::diskspace,
strum::{Display, EnumString, IntoEnumIterator},
@@ -44,6 +45,7 @@ pub struct LidarrData<'a> {
pub disk_space_vec: Vec<DiskSpace>,
pub downloads: StatefulTable<DownloadRecord>,
pub edit_artist_modal: Option<EditArtistModal>,
pub history: StatefulTable<LidarrHistoryItem>,
pub main_tabs: TabState,
pub metadata_profile_map: BiMap<i64, String>,
pub prompt_confirm: bool,
@@ -112,6 +114,7 @@ impl<'a> Default for LidarrData<'a> {
disk_space_vec: Vec::new(),
downloads: StatefulTable::default(),
edit_artist_modal: None,
history: StatefulTable::default(),
metadata_profile_map: BiMap::new(),
prompt_confirm: false,
prompt_confirm_action: None,
@@ -121,12 +124,20 @@ impl<'a> Default for LidarrData<'a> {
start_time: DateTime::default(),
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,
}]),
main_tabs: TabState::new(vec![
TabRoute {
title: "Library".to_string(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: Some(&ARTISTS_CONTEXT_CLUES),
config: None,
},
TabRoute {
title: "History".to_string(),
route: ActiveLidarrBlock::History.into(),
contextual_help: Some(&HISTORY_CONTEXT_CLUES),
config: None,
},
]),
artist_info_tabs: TabState::new(vec![TabRoute {
title: "Albums".to_string(),
route: ActiveLidarrBlock::ArtistDetails.into(),
@@ -195,6 +206,13 @@ impl LidarrData<'_> {
lidarr_data.artists.search = Some("artist search".into());
lidarr_data.artists.filter = Some("artist filter".into());
lidarr_data.downloads.set_items(vec![download_record()]);
lidarr_data.history.set_items(vec![lidarr_history_item()]);
lidarr_data.history.sorting(vec![SortOption {
name: "Date",
cmp_fn: Some(|a: &LidarrHistoryItem, b: &LidarrHistoryItem| a.date.cmp(&b.date)),
}]);
lidarr_data.history.search = Some("test search".into());
lidarr_data.history.filter = Some("test filter".into());
lidarr_data.root_folders.set_items(vec![root_folder()]);
lidarr_data.version = "1.0.0".to_owned();
lidarr_data.add_artist_search = Some("Test Artist".into());
@@ -244,10 +262,17 @@ pub enum ActiveLidarrBlock {
EditArtistToggleMonitored,
FilterArtists,
FilterArtistsError,
FilterHistory,
FilterHistoryError,
History,
HistoryItemDetails,
HistorySortPrompt,
SearchAlbums,
SearchAlbumsError,
SearchArtists,
SearchArtistsError,
SearchHistory,
SearchHistoryError,
UpdateAllArtistsPrompt,
UpdateAndScanArtistPrompt,
}
@@ -270,6 +295,16 @@ pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 5] = [
ActiveLidarrBlock::UpdateAndScanArtistPrompt,
];
pub static HISTORY_BLOCKS: [ActiveLidarrBlock; 7] = [
ActiveLidarrBlock::History,
ActiveLidarrBlock::HistoryItemDetails,
ActiveLidarrBlock::HistorySortPrompt,
ActiveLidarrBlock::SearchHistory,
ActiveLidarrBlock::SearchHistoryError,
ActiveLidarrBlock::FilterHistory,
ActiveLidarrBlock::FilterHistoryError,
];
pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 12] = [
ActiveLidarrBlock::AddArtistAlreadyInLibrary,
ActiveLidarrBlock::AddArtistConfirmPrompt,
@@ -1,5 +1,6 @@
#[cfg(test)]
mod tests {
use crate::app::context_clues::HISTORY_CONTEXT_CLUES;
use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
};
@@ -7,7 +8,7 @@ mod tests {
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ARTIST_DETAILS_BLOCKS, DELETE_ALBUM_BLOCKS,
DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS,
EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS,
EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, HISTORY_BLOCKS,
};
use crate::models::{
BlockSelectionState, Route,
@@ -134,6 +135,7 @@ mod tests {
assert_is_empty!(lidarr_data.disk_space_vec);
assert_is_empty!(lidarr_data.downloads);
assert_none!(lidarr_data.edit_artist_modal);
assert_is_empty!(lidarr_data.history);
assert_is_empty!(lidarr_data.metadata_profile_map);
assert!(!lidarr_data.prompt_confirm);
assert_none!(lidarr_data.prompt_confirm_action);
@@ -144,7 +146,7 @@ mod tests {
assert_is_empty!(lidarr_data.tags_map);
assert_is_empty!(lidarr_data.version);
assert_eq!(lidarr_data.main_tabs.tabs.len(), 1);
assert_eq!(lidarr_data.main_tabs.tabs.len(), 2);
assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library");
assert_eq!(
@@ -157,6 +159,17 @@ mod tests {
);
assert_none!(lidarr_data.main_tabs.tabs[0].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[1].title, "History");
assert_eq!(
lidarr_data.main_tabs.tabs[1].route,
ActiveLidarrBlock::History.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[1].contextual_help,
&HISTORY_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[1].config);
assert_eq!(lidarr_data.artist_info_tabs.tabs.len(), 1);
assert_str_eq!(lidarr_data.artist_info_tabs.tabs[0].title, "Albums");
assert_eq!(
@@ -192,6 +205,18 @@ mod tests {
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt));
}
#[test]
fn test_history_blocks_contains_expected_blocks() {
assert_eq!(HISTORY_BLOCKS.len(), 7);
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::History));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::HistoryItemDetails));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::HistorySortPrompt));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::SearchHistory));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::SearchHistoryError));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::FilterHistory));
assert!(HISTORY_BLOCKS.contains(&ActiveLidarrBlock::FilterHistoryError));
}
#[test]
fn test_add_artist_blocks_contents() {
assert_eq!(ADD_ARTIST_BLOCKS.len(), 12);
@@ -2,12 +2,11 @@ use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal};
use crate::{
app::{
context_clues::{
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
},
sonarr::sonarr_context_clues::{
HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES,
SERIES_HISTORY_CONTEXT_CLUES,
SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES,
},
},
models::{
@@ -1,6 +1,7 @@
#[cfg(test)]
mod tests {
mod sonarr_data_tests {
use crate::app::context_clues::HISTORY_CONTEXT_CLUES;
use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES;
use crate::models::sonarr_models::{Season, SonarrHistoryItem};
use crate::models::stateful_table::StatefulTable;
@@ -10,9 +11,7 @@ mod tests {
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
},
sonarr::sonarr_context_clues::{
HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES,
},
sonarr::sonarr_context_clues::{SERIES_CONTEXT_CLUES, SERIES_DETAILS_CONTEXT_CLUES},
},
models::{
BlockSelectionState, Route,
@@ -0,0 +1,199 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrHistoryWrapper, LidarrSerdeable};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::SortOption;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::lidarr_history_item;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use rstest::rstest;
use serde_json::json;
#[rstest]
#[tokio::test]
async fn test_handle_get_lidarr_history_event(#[values(true, false)] use_custom_sorting: bool) {
let history_json = json!({"records": [{
"id": 123,
"sourceTitle": "z album",
"albumId": 1007,
"artistId": 1007,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
},
{
"id": 456,
"sourceTitle": "An Album",
"albumId": 2001,
"artistId": 2001,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
}]});
let response: LidarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("pageSize=500&sortDirection=descending&sortKey=date")
.build_for(LidarrEvent::GetHistory(500))
.await;
let mut expected_history_items = vec![
LidarrHistoryItem {
id: 123,
album_id: 1007,
artist_id: 1007,
source_title: "z album".into(),
..lidarr_history_item()
},
LidarrHistoryItem {
id: 456,
album_id: 2001,
artist_id: 2001,
source_title: "An Album".into(),
..lidarr_history_item()
},
];
{
let mut app_mut = app.lock().await;
app_mut.server_tabs.set_index(2);
app_mut.data.lidarr_data.history.sort_asc = true;
}
if use_custom_sorting {
let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
};
expected_history_items.sort_by(cmp_fn);
let history_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.history
.sorting(vec![history_sort_option]);
}
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetHistory(500))
.await;
mock.assert_async().await;
assert!(result.is_ok());
let LidarrSerdeable::HistoryWrapper(history) = result.unwrap() else {
panic!("Expected LidarrHistoryWrapper")
};
assert_eq!(
app.lock().await.data.lidarr_data.history.items,
expected_history_items
);
assert!(app.lock().await.data.lidarr_data.history.sort_asc);
assert_eq!(history, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_history_event_no_op_when_user_is_selecting_sort_options() {
let history_json = json!({"records": [{
"id": 123,
"sourceTitle": "z album",
"albumId": 1007,
"artistId": 1007,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
},
{
"id": 456,
"sourceTitle": "An Album",
"albumId": 2001,
"artistId": 2001,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
}]});
let response: LidarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("pageSize=500&sortDirection=descending&sortKey=date")
.build_for(LidarrEvent::GetHistory(500))
.await;
app.lock().await.data.lidarr_data.history.sort_asc = true;
app
.lock()
.await
.push_navigation_stack(ActiveLidarrBlock::HistorySortPrompt.into());
let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
};
let history_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.history
.sorting(vec![history_sort_option]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::HistoryWrapper(history) = network
.handle_lidarr_event(LidarrEvent::GetHistory(500))
.await
.unwrap()
else {
panic!("Expected LidarrHistoryWrapper")
};
mock.assert_async().await;
assert!(app.lock().await.data.lidarr_data.history.is_empty());
assert!(app.lock().await.data.lidarr_data.history.sort_asc);
assert_eq!(history, response);
}
#[tokio::test]
async fn test_handle_mark_lidarr_history_item_as_failed_event() {
let history_item_id = 1234i64;
let (mock, app, _server) = MockServarrApi::post()
.returns(json!({}))
.path("/1234")
.build_for(LidarrEvent::MarkHistoryItemAsFailed(history_item_id))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id))
.await;
mock.assert_async().await;
assert!(result.is_ok());
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::models::Route;
use crate::models::lidarr_models::LidarrHistoryWrapper;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use anyhow::Result;
use log::info;
use serde_json::Value;
#[cfg(test)]
#[path = "lidarr_history_network_tests.rs"]
mod lidarr_history_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn get_lidarr_history(
&mut self,
events: u64,
) -> Result<LidarrHistoryWrapper> {
info!("Fetching all Lidarr history events");
let event = LidarrEvent::GetHistory(events);
let params = format!("pageSize={events}&sortDirection=descending&sortKey=date");
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params))
.await;
self
.handle_request::<(), LidarrHistoryWrapper>(request_props, |history_response, mut app| {
if !matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::HistorySortPrompt, _)
) {
let mut history_vec = history_response.records;
history_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.lidarr_data.history.set_items(history_vec);
app.data.lidarr_data.history.apply_sorting_toggle(false);
}
})
.await
}
pub(in crate::network::lidarr_network) async fn mark_lidarr_history_item_as_failed(
&mut self,
history_item_id: i64,
) -> Result<Value> {
info!("Marking the Lidarr history item with ID: {history_item_id} as 'failed'");
let event = LidarrEvent::MarkHistoryItemAsFailed(history_item_id);
let request_props = self
.request_props_from(
event,
RequestMethod::Post,
None,
Some(format!("/{history_item_id}")),
None,
)
.await;
self
.handle_request::<(), Value>(request_props, |_, _| ())
.await
}
}
@@ -4,10 +4,11 @@ pub mod test_utils {
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, Member, MetadataProfile,
DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, LidarrHistoryData,
LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, Member, MetadataProfile,
NewItemMonitorType, Ratings, SystemStatus,
};
use crate::models::servarr_models::{QualityProfile, RootFolder, Tag};
use crate::models::servarr_models::{Quality, QualityProfile, QualityWrapper, RootFolder, Tag};
use bimap::BiMap;
use chrono::DateTime;
use serde_json::Number;
@@ -120,6 +121,16 @@ pub mod test_utils {
}
}
pub fn quality_wrapper() -> QualityWrapper {
QualityWrapper { quality: quality() }
}
pub fn quality() -> Quality {
Quality {
name: "Lossless".to_string(),
}
}
pub fn quality_profile() -> QualityProfile {
QualityProfile {
id: 1,
@@ -249,4 +260,31 @@ pub mod test_utils {
statistics: Some(album_statistics()),
}
}
pub fn lidarr_history_wrapper() -> LidarrHistoryWrapper {
LidarrHistoryWrapper {
records: vec![lidarr_history_item()],
}
}
pub fn lidarr_history_item() -> LidarrHistoryItem {
LidarrHistoryItem {
id: 1,
source_title: "Test source title".into(),
album_id: 1,
artist_id: 1,
quality: quality_wrapper(),
date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
event_type: LidarrHistoryEventType::Grabbed,
data: lidarr_history_data(),
}
}
pub fn lidarr_history_data() -> LidarrHistoryData {
LidarrHistoryData {
dropped_path: Some("/nfs/nzbget/completed/music/Something/cool.mp3".to_owned()),
imported_path: Some("/nfs/music/Something/Album 1/Cool.mp3".to_owned()),
..LidarrHistoryData::default()
}
}
}
@@ -30,6 +30,11 @@ mod tests {
assert_str_eq!(event.resource(), "/artist");
}
#[rstest]
fn test_resource_history(#[values(LidarrEvent::GetHistory(0))] event: LidarrEvent) {
assert_str_eq!(event.resource(), "/history");
}
#[rstest]
fn test_resource_tag(
#[values(
@@ -83,6 +88,7 @@ mod tests {
#[case(LidarrEvent::GetStatus, "/system/status")]
#[case(LidarrEvent::GetTags, "/tag")]
#[case(LidarrEvent::HealthCheck, "/health")]
#[case(LidarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")]
fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) {
assert_str_eq!(event.resource(), expected_uri);
}
+13
View File
@@ -9,6 +9,7 @@ use crate::models::servarr_models::{QualityProfile, Tag};
use crate::network::{Network, RequestMethod};
mod downloads;
mod history;
mod library;
mod root_folders;
mod system;
@@ -34,7 +35,9 @@ pub enum LidarrEvent {
GetArtistDetails(i64),
GetDiskSpace,
GetDownloads(u64),
GetHistory(u64),
GetHostConfig,
MarkHistoryItemAsFailed(i64),
GetMetadataProfiles,
GetQualityProfiles,
GetRootFolders,
@@ -67,6 +70,8 @@ impl NetworkResource for LidarrEvent {
| LidarrEvent::DeleteAlbum(_) => "/album",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) => "/queue",
LidarrEvent::GetHistory(_) => "/history",
LidarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed",
LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host",
LidarrEvent::TriggerAutomaticArtistSearch(_)
| LidarrEvent::UpdateAllArtists
@@ -120,6 +125,14 @@ impl Network<'_, '_> {
.get_lidarr_downloads(count)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetHistory(events) => self
.get_lidarr_history(events)
.await
.map(LidarrSerdeable::from),
LidarrEvent::MarkHistoryItemAsFailed(history_item_id) => self
.mark_lidarr_history_item_as_failed(history_item_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetHostConfig => self
.get_lidarr_host_config()
.await
@@ -0,0 +1,79 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::history::HistoryUi;
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
fn test_history_ui_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if HISTORY_BLOCKS.contains(&active_lidarr_block) {
assert!(HistoryUi::accepts(active_lidarr_block.into()));
} else {
assert!(!HistoryUi::accepts(active_lidarr_block.into()));
}
});
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
use super::*;
#[test]
fn test_history_ui_renders_loading() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::History.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
HistoryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[rstest]
fn test_history_ui_renders_empty(
#[values(ActiveLidarrBlock::History, ActiveLidarrBlock::HistoryItemDetails)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
HistoryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(format!("loading_history_tab_{active_lidarr_block}"), output);
}
#[rstest]
fn test_history_ui_renders(
#[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_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
HistoryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(format!("history_tab_{active_lidarr_block}"), output);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
use super::lidarr_ui_utils::create_history_event_details;
use crate::app::App;
use crate::models::Route;
use crate::models::lidarr_models::LidarrHistoryItem;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, HISTORY_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::styles::{ManagarrStyle, secondary_style};
use crate::ui::utils::{get_width_from_percentage, layout_block_top_border};
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::message::Message;
use crate::ui::widgets::popup::{Popup, Size};
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::Constraint;
use ratatui::text::Text;
use ratatui::widgets::{Cell, Row};
#[cfg(test)]
#[path = "history_ui_tests.rs"]
mod history_ui_tests;
pub(super) struct HistoryUi;
impl DrawUi for HistoryUi {
fn accepts(route: Route) -> bool {
if let Route::Lidarr(active_lidarr_block, _) = route {
return HISTORY_BLOCKS.contains(&active_lidarr_block);
}
false
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
draw_history_table(f, app, area);
if active_lidarr_block == ActiveLidarrBlock::HistoryItemDetails {
draw_history_item_details_popup(f, app);
}
}
}
}
fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let current_selection = if app.data.lidarr_data.history.items.is_empty() {
LidarrHistoryItem::default()
} else {
app.data.lidarr_data.history.current_selection().clone()
};
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
let history_row_mapping = |history_item: &LidarrHistoryItem| {
let LidarrHistoryItem {
source_title,
quality,
event_type,
date,
..
} = history_item;
source_title.scroll_left_or_reset(
get_width_from_percentage(area, 50),
current_selection == *history_item,
app.ui_scroll_tick_count == 0,
);
Row::new(vec![
Cell::from(source_title.to_string()),
Cell::from(event_type.to_string()),
Cell::from(quality.quality.name.to_owned()),
Cell::from(date.to_string()),
])
.primary()
};
let history_table =
ManagarrTable::new(Some(&mut app.data.lidarr_data.history), history_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.sorting(active_lidarr_block == ActiveLidarrBlock::HistorySortPrompt)
.searching(active_lidarr_block == ActiveLidarrBlock::SearchHistory)
.search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchHistoryError)
.filtering(active_lidarr_block == ActiveLidarrBlock::FilterHistory)
.filter_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::FilterHistoryError)
.headers(["Source Title", "Event Type", "Quality", "Date"])
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(18),
Constraint::Percentage(12),
Constraint::Percentage(20),
]);
if [
ActiveLidarrBlock::SearchHistory,
ActiveLidarrBlock::FilterHistory,
]
.contains(&active_lidarr_block)
{
history_table.show_cursor(f, area);
}
f.render_widget(history_table, area);
}
}
fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) {
let current_selection = if app.data.lidarr_data.history.items.is_empty() {
LidarrHistoryItem::default()
} else {
app.data.lidarr_data.history.current_selection().clone()
};
let line_vec = create_history_event_details(current_selection);
let text = Text::from(line_vec);
let message = Message::new(text)
.title("Details")
.style(secondary_style())
.alignment(Alignment::Left);
f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area());
}
@@ -0,0 +1,28 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭───────────────── Filter ──────────────────╮
│test filter │
╰─────────────────────────────────────────────╯
@@ -0,0 +1,31 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭─────────────── Error ───────────────╮
│The given filter produced empty results│
│ │
╰───────────────────────────────────────╯
@@ -0,0 +1,7 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
@@ -0,0 +1,34 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭─────────────────────────────────── Details ───────────────────────────────────╮
│Source Title: Test source title │
│Event Type: grabbed │
│Quality: Lossless │
│Date: 2023-01-01 00:00:00 UTC │
│Indexer: │
│NZB Info URL: │
│Release Group: │
│Age: 0 days │
╰─────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,42 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭───────────────────────────────╮
│Date │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────╯
@@ -0,0 +1,28 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭───────────────── Search ──────────────────╮
│test search │
╰─────────────────────────────────────────────╯
@@ -0,0 +1,31 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Source Title ▼ Event Type Quality Date
=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC
╭─────────────── Error ───────────────╮
│ No items found matching search │
│ │
╰───────────────────────────────────────╯
@@ -0,0 +1,8 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Loading ...
@@ -0,0 +1,5 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,34 @@
---
source: src/ui/lidarr_ui/history/history_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭─────────────────────────────────── Details ───────────────────────────────────╮
│Source Title: │
│Event Type: unknown │
│Quality: │
│Date: 1970-01-01 00:00:00 UTC │
│No additional details available. │
│ │
│ │
│ │
╰─────────────────────────────────────────────────────────────────────────────────╯
+153
View File
@@ -0,0 +1,153 @@
use crate::models::lidarr_models::{LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem};
use ratatui::text::Line;
#[cfg(test)]
#[path = "lidarr_ui_utils_tests.rs"]
mod lidarr_ui_utils_tests;
pub(super) fn create_history_event_details(history_item: LidarrHistoryItem) -> Vec<Line<'static>> {
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item;
let LidarrHistoryData {
indexer,
nzb_info_url,
release_group,
age,
published_date,
download_client_name,
download_client,
message,
reason,
dropped_path,
imported_path,
source_path,
path,
status_messages,
} = data;
let mut lines = vec![
Line::from(format!("Source Title: {}", source_title.text)),
Line::from(format!("Event Type: {event_type}")),
Line::from(format!("Quality: {}", quality.quality.name)),
Line::from(format!("Date: {date}")),
];
match event_type {
LidarrHistoryEventType::Grabbed => {
lines.push(Line::from(format!(
"Indexer: {}",
indexer.unwrap_or_default()
)));
lines.push(Line::from(format!(
"NZB Info URL: {}",
nzb_info_url.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Age: {} days",
age.unwrap_or("0".to_owned())
)));
lines.push(Line::from(format!(
"Published Date: {}",
published_date.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Download Client: {}",
download_client_name.unwrap_or(download_client.unwrap_or_default())
)));
}
LidarrHistoryEventType::DownloadImported => {
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
LidarrHistoryEventType::DownloadFailed => {
lines.push(Line::from(format!(
"Download Client: {}",
download_client_name.unwrap_or(download_client.unwrap_or_default())
)));
lines.push(Line::from(format!(
"Message: {}",
message.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Indexer: {}",
indexer.unwrap_or_default()
)));
}
LidarrHistoryEventType::TrackFileDeleted => {
lines.push(Line::from(format!(
"Reason: {}",
reason.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
LidarrHistoryEventType::TrackFileImported => {
lines.push(Line::from(format!(
"Dropped Path: {}",
dropped_path.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Imported Path: {}",
imported_path.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Download Client: {}",
download_client_name.unwrap_or(download_client.unwrap_or_default())
)));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
LidarrHistoryEventType::TrackFileRenamed => {
lines.push(Line::from(format!(
"Source Path: {}",
source_path.unwrap_or_default()
)));
lines.push(Line::from(format!("Path: {}", path.unwrap_or_default())));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
LidarrHistoryEventType::TrackFileRetagged => {
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
LidarrHistoryEventType::AlbumImportIncomplete => {
lines.push(Line::from(format!(
"Status Messages: {}",
status_messages.unwrap_or_default()
)));
lines.push(Line::from(format!(
"Release Group: {}",
release_group.unwrap_or_default()
)));
}
_ => {
lines.push(Line::from("No additional details available.".to_owned()));
}
}
lines
}
+421
View File
@@ -0,0 +1,421 @@
#[cfg(test)]
mod tests {
use chrono::Utc;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
use crate::models::lidarr_models::{
LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem,
};
use crate::models::servarr_models::{Quality, QualityWrapper};
use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details;
#[test]
fn test_create_history_event_details_grabbed() {
let history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Indexer: {}", data.indexer.unwrap()))
);
assert_eq!(
result[5],
Line::from(format!("NZB Info URL: {}", data.nzb_info_url.unwrap()))
);
assert_eq!(
result[6],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(
result[7],
Line::from(format!("Age: {} days", data.age.unwrap()))
);
assert_eq!(
result[8],
Line::from(format!("Published Date: {}", data.published_date.unwrap()))
);
assert_eq!(
result[9],
Line::from(format!(
"Download Client: {}",
data.download_client_name.unwrap()
))
);
assert_eq!(result.len(), 10);
}
#[test]
fn test_create_history_event_details_grabbed_uses_download_client_as_fallback() {
let mut history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed);
history_item.data.download_client_name = None;
history_item.data.download_client = Some("Fallback Client".to_owned());
let result = create_history_event_details(history_item);
assert_eq!(result[9], Line::from("Download Client: Fallback Client"));
}
#[test]
fn test_create_history_event_details_download_imported() {
let history_item = lidarr_history_item(LidarrHistoryEventType::DownloadImported);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 5);
}
#[test]
fn test_create_history_event_details_download_failed() {
let history_item = lidarr_history_item(LidarrHistoryEventType::DownloadFailed);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!(
"Download Client: {}",
data.download_client_name.unwrap()
))
);
assert_eq!(
result[5],
Line::from(format!("Message: {}", data.message.unwrap()))
);
assert_eq!(
result[6],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(
result[7],
Line::from(format!("Indexer: {}", data.indexer.unwrap()))
);
assert_eq!(result.len(), 8);
}
#[test]
fn test_create_history_event_details_track_file_deleted() {
let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileDeleted);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Reason: {}", data.reason.unwrap()))
);
assert_eq!(
result[5],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 6);
}
#[test]
fn test_create_history_event_details_track_file_imported() {
let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileImported);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Dropped Path: {}", data.dropped_path.unwrap()))
);
assert_eq!(
result[5],
Line::from(format!("Imported Path: {}", data.imported_path.unwrap()))
);
assert_eq!(
result[6],
Line::from(format!(
"Download Client: {}",
data.download_client_name.unwrap()
))
);
assert_eq!(
result[7],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 8);
}
#[test]
fn test_create_history_event_details_track_file_renamed() {
let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileRenamed);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Source Path: {}", data.source_path.unwrap()))
);
assert_eq!(
result[5],
Line::from(format!("Path: {}", data.path.unwrap()))
);
assert_eq!(
result[6],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 7);
}
#[test]
fn test_create_history_event_details_track_file_retagged() {
let history_item = lidarr_history_item(LidarrHistoryEventType::TrackFileRetagged);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 5);
}
#[test]
fn test_create_history_event_details_album_import_incomplete() {
let history_item = lidarr_history_item(LidarrHistoryEventType::AlbumImportIncomplete);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
data,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(
result[4],
Line::from(format!(
"Status Messages: {}",
data.status_messages.unwrap()
))
);
assert_eq!(
result[5],
Line::from(format!("Release Group: {}", data.release_group.unwrap()))
);
assert_eq!(result.len(), 6);
}
#[test]
fn test_create_history_event_details_unknown() {
let history_item = lidarr_history_item(LidarrHistoryEventType::Unknown);
let LidarrHistoryItem {
source_title,
event_type,
quality,
date,
..
} = history_item.clone();
let result = create_history_event_details(history_item);
assert_eq!(
result[0],
Line::from(format!("Source Title: {}", source_title.text))
);
assert_eq!(result[1], Line::from(format!("Event Type: {event_type}")));
assert_eq!(
result[2],
Line::from(format!("Quality: {}", quality.quality.name))
);
assert_eq!(result[3], Line::from(format!("Date: {date}")));
assert_eq!(result[4], Line::from("No additional details available."));
assert_eq!(result.len(), 5);
}
#[test]
fn test_create_history_event_details_with_empty_optional_fields() {
let mut history_item = lidarr_history_item(LidarrHistoryEventType::Grabbed);
history_item.data = LidarrHistoryData::default();
let result = create_history_event_details(history_item);
assert_eq!(result[4], Line::from("Indexer: "));
assert_eq!(result[5], Line::from("NZB Info URL: "));
assert_eq!(result[6], Line::from("Release Group: "));
assert_eq!(result[7], Line::from("Age: 0 days"));
assert!(result[8].to_string().starts_with("Published Date:"));
assert_eq!(result[9], Line::from("Download Client: "));
}
fn lidarr_history_item(event_type: LidarrHistoryEventType) -> LidarrHistoryItem {
LidarrHistoryItem {
id: 1,
source_title: "Test Album - Artist Name".into(),
album_id: 100,
artist_id: 10,
event_type,
quality: QualityWrapper {
quality: Quality {
name: "FLAC".to_owned(),
},
},
date: Utc::now(),
data: lidarr_history_data(),
}
}
fn lidarr_history_data() -> LidarrHistoryData {
LidarrHistoryData {
indexer: Some("Test Indexer".to_owned()),
release_group: Some("Test Release Group".to_owned()),
nzb_info_url: Some("https://test.url".to_owned()),
download_client_name: Some("Test Download Client".to_owned()),
download_client: Some("Fallback Download Client".to_owned()),
age: Some("7".to_owned()),
published_date: Some(Utc::now()),
message: Some("Test failure message".to_owned()),
reason: Some("Test deletion reason".to_owned()),
dropped_path: Some("/downloads/completed/album".to_owned()),
imported_path: Some("/music/artist/album".to_owned()),
source_path: Some("/music/artist/old_album_name".to_owned()),
path: Some("/music/artist/new_album_name".to_owned()),
status_messages: Some("Missing tracks: 1, 2, 3".to_owned()),
}
}
}
+4
View File
@@ -5,6 +5,7 @@ use crate::ui::ui_test_utils::test_utils::Utc;
use chrono::Duration;
#[cfg(not(test))]
use chrono::Utc;
use history::HistoryUi;
use library::LibraryUi;
use ratatui::{
Frame,
@@ -35,7 +36,9 @@ use super::{
widgets::loading_block::LoadingBlock,
};
mod history;
mod library;
mod lidarr_ui_utils;
#[cfg(test)]
#[path = "lidarr_ui_tests.rs"]
@@ -54,6 +57,7 @@ impl DrawUi for LidarrUi {
match route {
_ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area),
_ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area),
_ => (),
}
}