diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index d6517da..4b53013 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -57,6 +57,24 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_blocklist_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.lidarr_data.prompt_confirm = true; + app.data.lidarr_data.artists.set_items(vec![artist()]); + app.network_tx = Some(tx); + + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::Blocklist) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetBlocklist.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_artist_history_block() { let (tx, mut rx) = mpsc::channel::(500); diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index 3dca060..a58234c 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -27,6 +27,11 @@ impl App<'_> { .dispatch_network_event(LidarrEvent::ListArtists.into()) .await; } + ActiveLidarrBlock::Blocklist => { + self + .dispatch_network_event(LidarrEvent::GetBlocklist.into()) + .await; + } ActiveLidarrBlock::Downloads => { self .dispatch_network_event(LidarrEvent::GetDownloads(500).into()) diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index 6ec6f22..08fb287 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -8,12 +8,18 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; + use crate::cli::lidarr::LidarrCommand; + use crate::network::lidarr_network::LidarrEvent; use crate::{ Cli, app::App, cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, models::{ Serdeable, + lidarr_models::{ + BlocklistItem as LidarrBlocklistItem, BlocklistResponse as LidarrBlocklistResponse, + LidarrSerdeable, + }, radarr_models::{ BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, RadarrSerdeable, @@ -182,5 +188,34 @@ mod tests { assert_ok!(&result); } - // TODO: Implement test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler + #[tokio::test] + async fn test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse( + LidarrBlocklistResponse { + records: vec![LidarrBlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let clear_blocklist_command = LidarrCommand::ClearBlocklist.into(); + + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; + + assert_ok!(&result); + } } diff --git a/src/cli/lidarr/delete_command_handler.rs b/src/cli/lidarr/delete_command_handler.rs index ef6db42..bd6a660 100644 --- a/src/cli/lidarr/delete_command_handler.rs +++ b/src/cli/lidarr/delete_command_handler.rs @@ -28,6 +28,15 @@ pub enum LidarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this album")] add_list_exclusion: bool, }, + #[command(about = "Delete the specified item from the Lidarr blocklist")] + BlocklistItem { + #[arg( + long, + help = "The ID of the blocklist item to remove from the blocklist", + required = true + )] + blocklist_item_id: i64, + }, #[command(about = "Delete the specified track file from disk")] TrackFile { #[arg(long, help = "The ID of the track file to delete", required = true)] @@ -107,6 +116,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + LidarrDeleteCommand::BlocklistItem { blocklist_item_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::DeleteBlocklistItem(blocklist_item_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrDeleteCommand::TrackFile { track_file_id } => { let resp = self .network diff --git a/src/cli/lidarr/delete_command_handler_tests.rs b/src/cli/lidarr/delete_command_handler_tests.rs index 41cb8cf..0d8c462 100644 --- a/src/cli/lidarr/delete_command_handler_tests.rs +++ b/src/cli/lidarr/delete_command_handler_tests.rs @@ -86,6 +86,42 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_blocklist_item_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "blocklist-item"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_blocklist_item_success() { + let expected_args = LidarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_track_file_requires_arguments() { let result = @@ -361,6 +397,37 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_blocklist_item_command() { + let expected_blocklist_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::DeleteBlocklistItem(expected_blocklist_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 delete_blocklist_item_command = LidarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = LidarrDeleteCommandHandler::with( + &app_arc, + delete_blocklist_item_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_track_file_command() { let expected_track_file_id = 1; diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 575d383..87dc654 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -25,7 +25,7 @@ mod tests { #[rstest] fn test_commands_that_have_no_arg_requirements( - #[values("test-all-indexers")] subcommand: &str, + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "lidarr", subcommand]); @@ -284,7 +284,9 @@ mod tests { use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand; use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand; - use crate::models::lidarr_models::{LidarrReleaseDownloadBody, LidarrTaskName}; + use crate::models::lidarr_models::{ + BlocklistItem, BlocklistResponse, LidarrReleaseDownloadBody, LidarrTaskName, + }; use crate::models::servarr_models::IndexerSettings; use crate::{ app::App, @@ -546,6 +548,39 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_clear_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse( + BlocklistResponse { + records: vec![BlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let claer_blocklist_command = LidarrCommand::ClearBlocklist; + + let result = LidarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_download_release_command() { let expected_release_download_body = LidarrReleaseDownloadBody { diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index 3790983..464acb6 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -57,6 +57,8 @@ pub enum LidarrListCommand { }, #[command(about = "List all artists in your Lidarr library")] Artists, + #[command(about = "List all items in the Lidarr blocklist")] + Blocklist, #[command(about = "List all active downloads in Lidarr")] Downloads { #[arg(long, help = "How many downloads to fetch", default_value_t = 500)] @@ -200,6 +202,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::Blocklist => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrListCommand::Downloads { count } => { let resp = self .network diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 48f92d3..35883fd 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -27,6 +27,7 @@ mod tests { fn test_list_commands_have_no_arg_requirements( #[values( "artists", + "blocklist", "indexers", "metadata-profiles", "quality-profiles", @@ -433,6 +434,7 @@ mod tests { #[rstest] #[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)] + #[case(LidarrListCommand::Blocklist, LidarrEvent::GetBlocklist)] #[case(LidarrListCommand::Indexers, LidarrEvent::GetIndexers)] #[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)] #[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)] diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 594f636..bbae67f 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -74,6 +74,8 @@ pub enum LidarrCommand { about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance" )] TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand), + #[command(about = "Clear the Lidarr blocklist")] + ClearBlocklist, #[command(about = "Manually download the given release")] DownloadRelease { #[arg(long, help = "The GUID of the release to download", required = true)] @@ -217,6 +219,17 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::ClearBlocklist => { + self + .network + .handle_network_event(LidarrEvent::GetBlocklist.into()) + .await?; + let resp = self + .network + .handle_network_event(LidarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrCommand::DownloadRelease { guid, indexer_id } => { let params = LidarrReleaseDownloadBody { guid, indexer_id }; let resp = self diff --git a/src/handlers/lidarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/lidarr_handlers/blocklist/blocklist_handler_tests.rs new file mode 100644 index 0000000..eda8e80 --- /dev/null +++ b/src/handlers/lidarr_handlers/blocklist/blocklist_handler_tests.rs @@ -0,0 +1,615 @@ +#[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::blocklist::{BlocklistHandler, blocklist_sorting_options}; + use crate::models::lidarr_models::{Artist, BlocklistItem}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::artist; + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_blocklist_item_prompt() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::DeleteBlocklistItemPrompt.into()); + } + + #[test] + fn test_delete_blocklist_item_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.lidarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + ActiveLidarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveLidarrBlock::Downloads.into()); + } + + #[rstest] + fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.lidarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.lidarr_data.main_tabs.get_active_route(), + ActiveLidarrBlock::History.into() + ); + assert_navigation_pushed!(app, ActiveLidarrBlock::History.into()); + } + + #[rstest] + fn test_blocklist_left_right_prompt_toggle( + #[values( + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt + )] + active_lidarr_block: ActiveLidarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new(key, &mut app, active_lidarr_block, None).handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + + BlocklistHandler::new(key, &mut app, active_lidarr_block, None).handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::network::lidarr_network::LidarrEvent; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_blocklist_submit() { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::BlocklistItemDetails.into()); + } + + #[test] + fn test_blocklist_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into()); + } + + #[rstest] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + LidarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt, + LidarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm_submit( + #[case] base_route: ActiveLidarrBlock, + #[case] prompt_block: ActiveLidarrBlock, + #[case] expected_action: LidarrEvent, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.data.lidarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + + #[rstest] + fn test_blocklist_prompt_decline_submit( + #[values( + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt + )] + prompt_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::DeleteBlocklistItemPrompt + )] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt + )] + fn test_blocklist_prompt_blocks_esc( + #[case] base_block: ActiveLidarrBlock, + #[case] prompt_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.lidarr_data.prompt_confirm = true; + + BlocklistHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_navigation_popped!(app, base_block.into()); + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_esc_blocklist_item_details() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveLidarrBlock::BlocklistItemDetails.into()); + + BlocklistHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::BlocklistItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.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::Blocklist.into()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::lidarr_network::LidarrEvent; + + use super::*; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[test] + fn test_refresh_blocklist_key() { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_clear_blocklist_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::BlocklistClearAllItemsPrompt.into()); + } + + #[test] + fn test_clear_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into()); + } + + #[rstest] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + LidarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt, + LidarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm( + #[case] base_route: ActiveLidarrBlock, + #[case] prompt_block: ActiveLidarrBlock, + #[case] expected_action: LidarrEvent, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + } + + #[test] + fn test_blocklist_sorting_options_artist_name() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.artist + .artist_name + .text + .to_lowercase() + .cmp(&b.artist.artist_name.text.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[0].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Artist Name"); + } + + #[test] + fn test_blocklist_sorting_options_source_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[1].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_blocklist_sorting_options_quality() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[2].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_blocklist_sorting_options_date() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[3].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + #[test] + fn test_blocklist_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_lidarr_block) { + assert!(BlocklistHandler::accepts(active_lidarr_block)); + } else { + assert!(!BlocklistHandler::accepts(active_lidarr_block)); + } + }) + } + + #[rstest] + fn test_blocklist_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 = BlocklistHandler::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_extract_blocklist_item_id() { + let mut app = App::test_default(); + app.data.lidarr_data.blocklist.set_items(blocklist_vec()); + + let blocklist_item_id = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ) + .extract_blocklist_item_id(); + + assert_eq!(blocklist_item_id, 3); + } + + #[test] + fn test_blocklist_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.is_loading = true; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_not_ready_when_blocklist_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.is_loading = false; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + app.is_loading = false; + app + .data + .lidarr_data + .blocklist + .set_items(vec![BlocklistItem::default()]); + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::Blocklist, + None, + ); + + assert!(handler.is_ready()); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 3, + source_title: "test 1".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "Lossless".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + artist: Artist { + artist_name: "test 3".into(), + ..artist() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + source_title: "test 2".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "Lossy".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + artist: Artist { + artist_name: "test 2".into(), + ..artist() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 1, + source_title: "test 3".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "Lossless".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + artist: Artist { + artist_name: "".into(), + ..artist() + }, + ..BlocklistItem::default() + }, + ] + } +} diff --git a/src/handlers/lidarr_handlers/blocklist/mod.rs b/src/handlers/lidarr_handlers/blocklist/mod.rs new file mode 100644 index 0000000..c2f2276 --- /dev/null +++ b/src/handlers/lidarr_handlers/blocklist/mod.rs @@ -0,0 +1,222 @@ +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, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::lidarr_models::BlocklistItem; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::stateful_table::SortOption; +use crate::network::lidarr_network::LidarrEvent; + +#[cfg(test)] +#[path = "blocklist_handler_tests.rs"] +mod blocklist_handler_tests; + +pub(super) struct BlocklistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl BlocklistHandler<'_, '_> { + fn extract_blocklist_item_id(&self) -> i64 { + self.app.data.lidarr_data.blocklist.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for BlocklistHandler<'a, 'b> { + fn handle(&mut self) { + let blocklist_table_handling_config = + TableHandlingConfig::new(ActiveLidarrBlock::Blocklist.into()) + .sorting_block(ActiveLidarrBlock::BlocklistSortPrompt.into()) + .sort_options(blocklist_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.lidarr_data.blocklist, + blocklist_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> Self { + BlocklistHandler { + 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.blocklist.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) { + if self.active_lidarr_block == ActiveLidarrBlock::Blocklist { + self + .app + .push_navigation_stack(ActiveLidarrBlock::DeleteBlocklistItemPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key), + ActiveLidarrBlock::DeleteBlocklistItemPrompt + | ActiveLidarrBlock::BlocklistClearAllItemsPrompt => handle_prompt_toggle(self.app, self.key), + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::DeleteBlocklistItemPrompt => { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DeleteBlocklistItem( + self.extract_blocklist_item_id(), + )); + } + + self.app.pop_navigation_stack(); + } + ActiveLidarrBlock::BlocklistClearAllItemsPrompt => { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::ClearBlocklist); + } + + self.app.pop_navigation_stack(); + } + ActiveLidarrBlock::Blocklist => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::BlocklistItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::DeleteBlocklistItemPrompt + | ActiveLidarrBlock::BlocklistClearAllItemsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.prompt_confirm = false; + } + ActiveLidarrBlock::BlocklistItemDetails | ActiveLidarrBlock::BlocklistSortPrompt => { + self.app.pop_navigation_stack(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_lidarr_block { + ActiveLidarrBlock::Blocklist => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(clear, key) => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::BlocklistClearAllItemsPrompt.into()); + } + _ => (), + }, + ActiveLidarrBlock::DeleteBlocklistItemPrompt => { + if matches_key!(confirm, key) { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DeleteBlocklistItem( + self.extract_blocklist_item_id(), + )); + + self.app.pop_navigation_stack(); + } + } + ActiveLidarrBlock::BlocklistClearAllItemsPrompt => { + if matches_key!(confirm, key) { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::ClearBlocklist); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn blocklist_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Artist Name", + cmp_fn: Some(|a, b| { + a.artist + .artist_name + .text + .to_lowercase() + .cmp(&b.artist.artist_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.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)), + }, + ] +} diff --git a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs index b349e0d..9c65988 100644 --- a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs @@ -99,9 +99,9 @@ mod tests { assert_eq!( app.data.lidarr_data.main_tabs.get_active_route(), - ActiveLidarrBlock::History.into() + ActiveLidarrBlock::Blocklist.into() ); - assert_navigation_pushed!(app, ActiveLidarrBlock::History.into()); + assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into()); } #[rstest] diff --git a/src/handlers/lidarr_handlers/history/history_handler_tests.rs b/src/handlers/lidarr_handlers/history/history_handler_tests.rs index 8cd4d3f..75c4e6f 100644 --- a/src/handlers/lidarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/lidarr_handlers/history/history_handler_tests.rs @@ -29,7 +29,7 @@ mod tests { 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(2); + app.data.lidarr_data.main_tabs.set_index(3); HistoryHandler::new( DEFAULT_KEYBINDINGS.left.key, @@ -41,9 +41,9 @@ mod tests { assert_eq!( app.data.lidarr_data.main_tabs.get_active_route(), - ActiveLidarrBlock::Downloads.into() + ActiveLidarrBlock::Blocklist.into() ); - assert_navigation_pushed!(app, ActiveLidarrBlock::Downloads.into()); + assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into()); } #[rstest] @@ -51,7 +51,7 @@ mod tests { 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(2); + app.data.lidarr_data.main_tabs.set_index(3); HistoryHandler::new( DEFAULT_KEYBINDINGS.right.key, diff --git a/src/handlers/lidarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/lidarr_handlers/indexers/indexers_handler_tests.rs index 1cbc471..0be65eb 100644 --- a/src/handlers/lidarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/lidarr_handlers/indexers/indexers_handler_tests.rs @@ -67,7 +67,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::Indexers.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(4); + app.data.lidarr_data.main_tabs.set_index(5); IndexersHandler::new( DEFAULT_KEYBINDINGS.left.key, @@ -89,7 +89,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::Indexers.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(4); + app.data.lidarr_data.main_tabs.set_index(5); IndexersHandler::new( DEFAULT_KEYBINDINGS.right.key, diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index 308d696..1a50709 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -53,11 +53,12 @@ mod tests { #[rstest] #[case(0, ActiveLidarrBlock::System, ActiveLidarrBlock::Downloads)] - #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::History)] - #[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::RootFolders)] - #[case(3, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)] - #[case(4, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)] - #[case(5, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)] + #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Blocklist)] + #[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::History)] + #[case(3, ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::RootFolders)] + #[case(4, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)] + #[case(5, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)] + #[case(6, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)] fn test_lidarr_handler_change_tab_left_right_keys( #[case] index: usize, #[case] left_block: ActiveLidarrBlock, @@ -87,11 +88,12 @@ mod tests { #[rstest] #[case(0, ActiveLidarrBlock::System, ActiveLidarrBlock::Downloads)] - #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::History)] - #[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::RootFolders)] - #[case(3, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)] - #[case(4, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)] - #[case(5, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)] + #[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Blocklist)] + #[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::History)] + #[case(3, ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::RootFolders)] + #[case(4, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)] + #[case(5, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)] + #[case(6, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)] fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation( #[case] index: usize, #[case] left_block: ActiveLidarrBlock, @@ -122,10 +124,11 @@ mod tests { #[rstest] #[case(0, ActiveLidarrBlock::Artists)] #[case(1, ActiveLidarrBlock::Downloads)] - #[case(2, ActiveLidarrBlock::History)] - #[case(3, ActiveLidarrBlock::RootFolders)] - #[case(4, ActiveLidarrBlock::Indexers)] - #[case(5, ActiveLidarrBlock::System)] + #[case(2, ActiveLidarrBlock::Blocklist)] + #[case(3, ActiveLidarrBlock::History)] + #[case(4, ActiveLidarrBlock::RootFolders)] + #[case(5, ActiveLidarrBlock::Indexers)] + #[case(6, ActiveLidarrBlock::System)] fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key( #[case] index: usize, #[case] block: ActiveLidarrBlock, @@ -197,6 +200,24 @@ mod tests { ); } + #[rstest] + fn test_delegates_blocklist_blocks_to_blocklist_handler( + #[values( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistItemDetails, + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt, + ActiveLidarrBlock::BlocklistSortPrompt + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + test_handler_delegation!( + LidarrHandler, + ActiveLidarrBlock::Blocklist, + active_lidarr_block + ); + } + #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs index e0f6474..7a102c2 100644 --- a/src/handlers/lidarr_handlers/mod.rs +++ b/src/handlers/lidarr_handlers/mod.rs @@ -3,6 +3,7 @@ use indexers::IndexersHandler; use library::LibraryHandler; use super::KeyEventHandler; +use crate::handlers::lidarr_handlers::blocklist::BlocklistHandler; use crate::handlers::lidarr_handlers::downloads::DownloadsHandler; use crate::handlers::lidarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::lidarr_handlers::system::SystemHandler; @@ -11,6 +12,7 @@ use crate::{ app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, }; +mod blocklist; mod downloads; mod history; mod indexers; @@ -38,6 +40,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b _ if DownloadsHandler::accepts(self.active_lidarr_block) => { DownloadsHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); } + _ if BlocklistHandler::accepts(self.active_lidarr_block) => { + BlocklistHandler::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(); } diff --git a/src/handlers/lidarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/lidarr_handlers/root_folders/root_folders_handler_tests.rs index 8e37557..18afae6 100644 --- a/src/handlers/lidarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/lidarr_handlers/root_folders/root_folders_handler_tests.rs @@ -71,7 +71,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::RootFolders.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(3); + app.data.lidarr_data.main_tabs.set_index(4); RootFoldersHandler::new( DEFAULT_KEYBINDINGS.left.key, @@ -93,7 +93,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::RootFolders.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(3); + app.data.lidarr_data.main_tabs.set_index(4); RootFoldersHandler::new( DEFAULT_KEYBINDINGS.right.key, diff --git a/src/handlers/lidarr_handlers/system/system_handler_tests.rs b/src/handlers/lidarr_handlers/system/system_handler_tests.rs index ef8271c..60e79a1 100644 --- a/src/handlers/lidarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/lidarr_handlers/system/system_handler_tests.rs @@ -27,7 +27,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::System.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(5); + app.data.lidarr_data.main_tabs.set_index(6); SystemHandler::new( DEFAULT_KEYBINDINGS.left.key, @@ -49,7 +49,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::System.into()); app.is_loading = is_ready; - app.data.lidarr_data.main_tabs.set_index(5); + app.data.lidarr_data.main_tabs.set_index(6); SystemHandler::new( DEFAULT_KEYBINDINGS.right.key, diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 84569b9..72cdd3c 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -499,6 +499,28 @@ pub struct LidarrReleaseDownloadBody { pub indexer_id: i64, } +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlocklistItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub artist_id: i64, + pub album_ids: Option>, + pub source_title: String, + pub quality: QualityWrapper, + pub date: DateTime, + pub protocol: String, + pub indexer: String, + pub message: String, + pub artist: Artist, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct BlocklistResponse { + pub records: Vec, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TrackFile { @@ -574,6 +596,7 @@ serde_enum_from!( Album(Album), Artist(Artist), Artists(Vec), + BlocklistResponse(BlocklistResponse), DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), LidarrHistoryWrapper(LidarrHistoryWrapper), diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 068f4d1..f59b464 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,10 +5,10 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - AddArtistSearchResult, Album, AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, - LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, - MediaInfo, Member, MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, Track, - TrackFile, + AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, + DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, + LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MetadataProfile, + MonitorType, NewItemMonitorType, SystemStatus, Track, TrackFile, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, @@ -276,6 +276,23 @@ mod tests { assert_eq!(lidarr_serdeable, LidarrSerdeable::Artist(artist)); } + #[test] + fn test_lidarr_serdeable_from_blocklist_response() { + let blocklist_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 1, + ..BlocklistItem::default() + }], + }; + + let lidarr_serdeable: LidarrSerdeable = blocklist_response.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::BlocklistResponse(blocklist_response) + ); + } + #[test] fn test_lidarr_serdeable_from_disk_spaces() { let disk_spaces = vec![DiskSpace { diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index ed5cd5e..31b2c9a 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -2,14 +2,14 @@ use serde_json::Number; use super::modals::{AddArtistModal, AddRootFolderModal, AlbumDetailsModal, EditArtistModal}; use crate::app::context_clues::{ - DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, }; use crate::app::lidarr::lidarr_context_clues::{ ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, }; -use crate::models::lidarr_models::{LidarrRelease, LidarrTask}; +use crate::models::lidarr_models::{BlocklistItem, LidarrRelease, LidarrTask}; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_models::{IndexerSettings, QueueEvent}; use crate::models::stateful_list::StatefulList; @@ -30,6 +30,7 @@ use { super::modals::TrackDetailsModal, crate::models::lidarr_models::{MonitorType, NewItemMonitorType}, crate::models::stateful_table::SortOption, + crate::network::lidarr_network::lidarr_network_test_utils::test_utils::blocklist_item, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::indexer_settings, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ @@ -64,6 +65,7 @@ pub struct LidarrData<'a> { pub artist_history: StatefulTable, pub artist_info_tabs: TabState, pub artists: StatefulTable, + pub blocklist: StatefulTable, pub delete_files: bool, pub discography_releases: StatefulTable, pub disk_space_vec: Vec, @@ -149,6 +151,7 @@ impl<'a> Default for LidarrData<'a> { album_details_modal: None, artist_history: StatefulTable::default(), artists: StatefulTable::default(), + blocklist: StatefulTable::default(), delete_files: false, discography_releases: StatefulTable::default(), disk_space_vec: Vec::new(), @@ -187,6 +190,12 @@ impl<'a> Default for LidarrData<'a> { contextual_help: Some(&DOWNLOADS_CONTEXT_CLUES), config: None, }, + TabRoute { + title: "Blocklist".to_string(), + route: ActiveLidarrBlock::Blocklist.into(), + contextual_help: Some(&BLOCKLIST_CONTEXT_CLUES), + config: None, + }, TabRoute { title: "History".to_string(), route: ActiveLidarrBlock::History.into(), @@ -377,6 +386,8 @@ impl LidarrData<'_> { }]); lidarr_data.artists.search = Some("artist search".into()); lidarr_data.artists.filter = Some("artist filter".into()); + lidarr_data.blocklist.set_items(vec![blocklist_item()]); + lidarr_data.blocklist.sorting(vec![sort_option!(id)]); lidarr_data.downloads.set_items(vec![download_record()]); lidarr_data.history.set_items(vec![lidarr_history_item()]); lidarr_data.history.sorting(vec![SortOption { @@ -444,6 +455,11 @@ pub enum ActiveLidarrBlock { AllIndexerSettingsPrompt, AutomaticallySearchAlbumPrompt, AutomaticallySearchArtistPrompt, + Blocklist, + BlocklistItemDetails, + DeleteBlocklistItemPrompt, + BlocklistClearAllItemsPrompt, + BlocklistSortPrompt, DeleteAlbumPrompt, DeleteAlbumConfirmPrompt, DeleteAlbumToggleDeleteFile, @@ -579,6 +595,14 @@ pub static ALBUM_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::DeleteTrackFilePrompt, ]; +pub static BLOCKLIST_BLOCKS: [ActiveLidarrBlock; 5] = [ + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistItemDetails, + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt, + ActiveLidarrBlock::BlocklistSortPrompt, +]; + pub static DOWNLOADS_BLOCKS: [ActiveLidarrBlock; 3] = [ ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 8c0f90d..308024b 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,8 +1,8 @@ #[cfg(test)] mod tests { use crate::app::context_clues::{ - DOWNLOADS_CONTEXT_CLUES, HISTORY_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, }; use crate::app::lidarr::lidarr_context_clues::{ ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, @@ -11,7 +11,7 @@ mod tests { use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, - ARTIST_DETAILS_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, + ARTIST_DETAILS_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, @@ -149,6 +149,7 @@ mod tests { assert_none!(lidarr_data.album_details_modal); assert_is_empty!(lidarr_data.artists); assert_is_empty!(lidarr_data.artist_history); + assert_is_empty!(lidarr_data.blocklist); assert!(!lidarr_data.delete_files); assert_is_empty!(lidarr_data.disk_space_vec); assert_is_empty!(lidarr_data.downloads); @@ -171,7 +172,7 @@ mod tests { assert_is_empty!(lidarr_data.updates); assert_is_empty!(lidarr_data.version); - assert_eq!(lidarr_data.main_tabs.tabs.len(), 6); + assert_eq!(lidarr_data.main_tabs.tabs.len(), 7); assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library"); assert_eq!( @@ -195,50 +196,61 @@ mod tests { ); assert_none!(lidarr_data.main_tabs.tabs[1].config); - assert_str_eq!(lidarr_data.main_tabs.tabs[2].title, "History"); + assert_str_eq!(lidarr_data.main_tabs.tabs[2].title, "Blocklist"); assert_eq!( lidarr_data.main_tabs.tabs[2].route, - ActiveLidarrBlock::History.into() + ActiveLidarrBlock::Blocklist.into() ); assert_some_eq_x!( &lidarr_data.main_tabs.tabs[2].contextual_help, - &HISTORY_CONTEXT_CLUES + &BLOCKLIST_CONTEXT_CLUES ); assert_none!(lidarr_data.main_tabs.tabs[2].config); - assert_str_eq!(lidarr_data.main_tabs.tabs[3].title, "Root Folders"); + assert_str_eq!(lidarr_data.main_tabs.tabs[3].title, "History"); assert_eq!( lidarr_data.main_tabs.tabs[3].route, - ActiveLidarrBlock::RootFolders.into() + ActiveLidarrBlock::History.into() ); assert_some_eq_x!( &lidarr_data.main_tabs.tabs[3].contextual_help, - &ROOT_FOLDERS_CONTEXT_CLUES + &HISTORY_CONTEXT_CLUES ); assert_none!(lidarr_data.main_tabs.tabs[3].config); - assert_str_eq!(lidarr_data.main_tabs.tabs[4].title, "Indexers"); + assert_str_eq!(lidarr_data.main_tabs.tabs[4].title, "Root Folders"); assert_eq!( lidarr_data.main_tabs.tabs[4].route, - ActiveLidarrBlock::Indexers.into() + ActiveLidarrBlock::RootFolders.into() ); assert_some_eq_x!( &lidarr_data.main_tabs.tabs[4].contextual_help, - &INDEXERS_CONTEXT_CLUES + &ROOT_FOLDERS_CONTEXT_CLUES ); assert_none!(lidarr_data.main_tabs.tabs[4].config); - assert_str_eq!(lidarr_data.main_tabs.tabs[5].title, "System"); + assert_str_eq!(lidarr_data.main_tabs.tabs[5].title, "Indexers"); assert_eq!( lidarr_data.main_tabs.tabs[5].route, - ActiveLidarrBlock::System.into() + ActiveLidarrBlock::Indexers.into() ); assert_some_eq_x!( &lidarr_data.main_tabs.tabs[5].contextual_help, - &SYSTEM_CONTEXT_CLUES + &INDEXERS_CONTEXT_CLUES ); assert_none!(lidarr_data.main_tabs.tabs[5].config); + assert_str_eq!(lidarr_data.main_tabs.tabs[6].title, "System"); + assert_eq!( + lidarr_data.main_tabs.tabs[6].route, + ActiveLidarrBlock::System.into() + ); + assert_some_eq_x!( + &lidarr_data.main_tabs.tabs[6].contextual_help, + &SYSTEM_CONTEXT_CLUES + ); + assert_none!(lidarr_data.main_tabs.tabs[6].config); + assert_eq!(lidarr_data.artist_info_tabs.tabs.len(), 3); assert_str_eq!(lidarr_data.artist_info_tabs.tabs[0].title, "Albums"); assert_eq!( @@ -326,6 +338,16 @@ mod tests { assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::DeleteTrackFilePrompt)); } + #[test] + fn test_blocklist_blocks_contents() { + assert_eq!(BLOCKLIST_BLOCKS.len(), 5); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::Blocklist)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistItemDetails)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteBlocklistItemPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistClearAllItemsPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistSortPrompt)); + } + #[test] fn test_downloads_blocks_contains_expected_blocks() { assert_eq!(DOWNLOADS_BLOCKS.len(), 3); diff --git a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs new file mode 100644 index 0000000..08540c6 --- /dev/null +++ b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs @@ -0,0 +1,353 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{Artist, BlocklistItem, BlocklistResponse, 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::{ + artist, blocklist_item, + }; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Number, json}; + + #[tokio::test] + async fn test_handle_clear_lidarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 1, + ..blocklist_item() + }, + BlocklistItem { + id: 2, + ..blocklist_item() + }, + BlocklistItem { + id: 3, + ..blocklist_item() + }, + ]; + let expected_request_json = json!({ "ids": [1, 2, 3]}); + let (mock, app, _server) = MockServarrApi::delete() + .with_request_body(expected_request_json) + .build_for(LidarrEvent::ClearBlocklist) + .await; + app + .lock() + .await + .data + .lidarr_data + .blocklist + .set_items(blocklist_items); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::ClearBlocklist) + .await + .is_ok() + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_lidarr_blocklist_item_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(LidarrEvent::DeleteBlocklistItem(1)) + .await; + app + .lock() + .await + .data + .lidarr_data + .blocklist + .set_items(vec![blocklist_item()]); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::DeleteBlocklistItem(1)) + .await + .is_ok() + ); + + mock.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_lidarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + let blocklist_json = json!({"records": [{ + "artistId": 1007, + "albumIds": [42020], + "sourceTitle": "z artist", + "quality": { "quality": { "name": "Lossless" }}, + "date": "2023-05-20T21:29:16Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123, + "artist": { + "id": 1, + "artistName": "Alex", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "path": "/nfs/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } + } + }, + { + "artistId": 2001, + "artistTitle": "Test Artist", + "albumIds": [42018], + "sourceTitle": "A Artist", + "quality": { "quality": { "name": "Lossless" }}, + "date": "2023-05-20T21:29:16Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456, + "artist": { + "id": 1, + "artistName": "Alex", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "path": "/nfs/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } + } + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let mut expected_blocklist = vec![ + BlocklistItem { + id: 123, + artist_id: 1007, + source_title: "z artist".into(), + album_ids: Some(vec![Number::from(42020)]), + ..blocklist_item() + }, + BlocklistItem { + id: 456, + artist_id: 2001, + source_title: "A Artist".into(), + album_ids: Some(vec![Number::from(42018)]), + ..blocklist_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(LidarrEvent::GetBlocklist) + .await; + app + .lock() + .await + .data + .lidarr_data + .artists + .set_items(vec![Artist { + id: 1007, + artist_name: "Z Artist".into(), + ..artist() + }]); + app.lock().await.data.lidarr_data.blocklist.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .lidarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::BlocklistResponse(blocklist) = network + .handle_lidarr_event(LidarrEvent::GetBlocklist) + .await + .unwrap() + else { + panic!("Expected BlocklistResponse") + }; + mock.assert_async().await; + assert_eq!( + app.lock().await.data.lidarr_data.blocklist.items, + expected_blocklist + ); + assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + + #[tokio::test] + async fn test_handle_get_lidarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let blocklist_json = json!({"records": [{ + "artistId": 1007, + "albumIds": [42020], + "sourceTitle": "z artist", + "quality": { "quality": { "name": "Lossless" }}, + "date": "2023-05-20T21:29:16Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123, + "artist": { + "id": 1, + "artistName": "Alex", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "path": "/nfs/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } + } + }, + { + "artistId": 2001, + "albumIds": [42018], + "sourceTitle": "A Artist", + "quality": { "quality": { "name": "Lossless" }}, + "date": "2023-05-20T21:29:16Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456, + "artist": { + "id": 1, + "artistName": "Alex", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "path": "/nfs/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } + } + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(LidarrEvent::GetBlocklist) + .await; + app.lock().await.data.lidarr_data.blocklist.sort_asc = true; + app + .lock() + .await + .push_navigation_stack(ActiveLidarrBlock::BlocklistSortPrompt.into()); + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .lidarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::BlocklistResponse(blocklist) = network + .handle_lidarr_event(LidarrEvent::GetBlocklist) + .await + .unwrap() + else { + panic!("Expected BlocklistResponse") + }; + mock.assert_async().await; + assert_is_empty!(app.lock().await.data.lidarr_data.blocklist); + assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } +} diff --git a/src/network/lidarr_network/blocklist/mod.rs b/src/network/lidarr_network/blocklist/mod.rs new file mode 100644 index 0000000..6f542e2 --- /dev/null +++ b/src/network/lidarr_network/blocklist/mod.rs @@ -0,0 +1,92 @@ +use crate::models::Route; +use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse}; +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, json}; + +#[cfg(test)] +#[path = "lidarr_blocklist_network_tests.rs"] +mod lidarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn clear_lidarr_blocklist(&mut self) -> Result<()> { + info!("Clearing Lidarr blocklist"); + let event = LidarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .lidarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({"ids": ids})), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn delete_lidarr_blocklist_item( + &mut self, + blocklist_item_id: i64, + ) -> Result<()> { + let event = LidarrEvent::DeleteBlocklistItem(blocklist_item_id); + info!("Deleting Lidarr blocklist item for item with id: {blocklist_item_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{blocklist_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Lidarr blocklist"); + let event = LidarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec: Vec = blocklist_resp.records; + blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.lidarr_data.blocklist.set_items(blocklist_vec); + app.data.lidarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index b2852b5..e4b73cd 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -3,10 +3,10 @@ pub mod test_utils { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus, - AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, - LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, - LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MetadataProfile, - NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile, + AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, + EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, + LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, + MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile, }; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ @@ -477,4 +477,25 @@ pub mod test_utils { track_file: Some(track_file()), } } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + artist_id: 1, + album_ids: Some(vec![1.into()]), + source_title: "Alex - Something".to_string(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + protocol: "usenet".to_string(), + indexer: "NZBgeek (Prowlarr)".to_string(), + message: "test message".to_string(), + artist: artist(), + } + } + + pub fn blocklist_response() -> BlocklistResponse { + BlocklistResponse { + records: vec![blocklist_item()], + } + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index e439266..289ff9a 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -159,6 +159,9 @@ mod tests { } #[rstest] + #[case(LidarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(LidarrEvent::DeleteBlocklistItem(0), "/blocklist")] + #[case(LidarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")] diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index b667384..ef52a96 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -9,6 +9,7 @@ use crate::models::lidarr_models::{ use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; +mod blocklist; mod downloads; mod history; mod indexers; @@ -29,8 +30,10 @@ pub enum LidarrEvent { AddArtist(AddArtistBody), AddRootFolder(AddLidarrRootFolderBody), AddTag(String), + ClearBlocklist, DeleteAlbum(DeleteParams), DeleteArtist(DeleteParams), + DeleteBlocklistItem(i64), DeleteDownload(i64), DeleteIndexer(i64), DeleteRootFolder(i64), @@ -47,6 +50,7 @@ pub enum LidarrEvent { GetArtistHistory(i64), GetAllIndexerSettings, GetArtistDetails(i64), + GetBlocklist, GetDiscographyReleases(i64), GetDiskSpace, GetDownloads(u64), @@ -87,7 +91,9 @@ impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag", + LidarrEvent::ClearBlocklist => "/blocklist/bulk", LidarrEvent::DeleteTrackFile(_) | LidarrEvent::GetTrackFiles(_) => "/trackfile", + LidarrEvent::DeleteBlocklistItem(_) => "/blocklist", LidarrEvent::GetAllIndexerSettings | LidarrEvent::EditAllIndexerSettings(_) => { "/config/indexer" } @@ -104,6 +110,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetArtistHistory(_) | LidarrEvent::GetAlbumHistory(_, _) | LidarrEvent::GetTrackHistory(_, _, _) => "/history/artist", + LidarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", LidarrEvent::GetLogs(_) => "/log", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue", @@ -157,12 +164,20 @@ impl Network<'_, '_> { .add_lidarr_root_folder(path) .await .map(LidarrSerdeable::from), + LidarrEvent::ClearBlocklist => self + .clear_lidarr_blocklist() + .await + .map(LidarrSerdeable::from), LidarrEvent::DeleteAlbum(params) => { self.delete_album(params).await.map(LidarrSerdeable::from) } LidarrEvent::DeleteArtist(params) => { self.delete_artist(params).await.map(LidarrSerdeable::from) } + LidarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_lidarr_blocklist_item(blocklist_item_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::DeleteDownload(download_id) => self .delete_lidarr_download(download_id) .await @@ -218,6 +233,7 @@ impl Network<'_, '_> { .get_album_releases(artist_id, album_id) .await .map(LidarrSerdeable::from), + LidarrEvent::GetBlocklist => self.get_lidarr_blocklist().await.map(LidarrSerdeable::from), LidarrEvent::GetDiscographyReleases(artist_id) => self .get_artist_discography_releases(artist_id) .await diff --git a/src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs b/src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs new file mode 100644 index 0000000..b0810ef --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs @@ -0,0 +1,74 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::blocklist::BlocklistUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_blocklist_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_lidarr_block) { + assert!(BlocklistUi::accepts(active_lidarr_block.into())); + } else { + assert!(!BlocklistUi::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_blocklist_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_blocklist_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_blocklist_ui_renders( + #[values( + ActiveLidarrBlock::Blocklist, + ActiveLidarrBlock::BlocklistItemDetails, + ActiveLidarrBlock::DeleteBlocklistItemPrompt, + ActiveLidarrBlock::BlocklistClearAllItemsPrompt, + ActiveLidarrBlock::BlocklistSortPrompt + )] + 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| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("blocklist_tab_{active_lidarr_block}"), output); + } + } +} diff --git a/src/ui/lidarr_ui/blocklist/mod.rs b/src/ui/lidarr_ui/blocklist/mod.rs new file mode 100644 index 0000000..6c2f055 --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/mod.rs @@ -0,0 +1,156 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::lidarr_models::BlocklistItem; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::layout_block_top_border; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +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, Constraint, Rect}; +use ratatui::style::Stylize; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; + +#[cfg(test)] +#[path = "blocklist_ui_tests.rs"] +mod blocklist_ui_tests; + +pub(super) struct BlocklistUi; + +impl DrawUi for BlocklistUi { + fn accepts(route: Route) -> bool { + if let Route::Lidarr(active_lidarr_block, _) = route { + return BLOCKLIST_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_blocklist_table(f, app, area); + + match active_lidarr_block { + ActiveLidarrBlock::BlocklistItemDetails => { + draw_blocklist_item_details_popup(f, app); + } + ActiveLidarrBlock::DeleteBlocklistItemPrompt => { + let prompt = format!( + "Do you want to remove this item from your blocklist: \n{}?", + app + .data + .lidarr_data + .blocklist + .current_selection() + .source_title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Remove Item from Blocklist") + .prompt(&prompt) + .yes_no_value(app.data.lidarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveLidarrBlock::BlocklistClearAllItemsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Clear Blocklist") + .prompt("Do you want to clear your blocklist?") + .yes_no_value(app.data.lidarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::SmallPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + let blocklist_row_mapping = |blocklist_item: &BlocklistItem| { + let BlocklistItem { + source_title, + artist, + quality, + date, + .. + } = blocklist_item; + + let title = artist.artist_name.text.to_owned(); + + Row::new(vec![ + Cell::from(title), + Cell::from(source_title.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let blocklist_table = ManagarrTable::new( + Some(&mut app.data.lidarr_data.blocklist), + blocklist_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_lidarr_block == ActiveLidarrBlock::BlocklistSortPrompt) + .headers(["Artist Name", "Source Title", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(27), + Constraint::Percentage(43), + Constraint::Percentage(13), + Constraint::Percentage(17), + ]); + + f.render_widget(blocklist_table, area); + } +} + +fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.lidarr_data.blocklist.items.is_empty() { + BlocklistItem::default() + } else { + app.data.lidarr_data.blocklist.current_selection().clone() + }; + let BlocklistItem { + source_title, + protocol, + indexer, + message, + .. + } = current_selection; + let text = Text::from(vec![ + Line::from(vec![ + "Name: ".bold().secondary(), + source_title.to_owned().secondary(), + ]), + Line::from(vec![ + "Protocol: ".bold().secondary(), + protocol.to_owned().secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.to_owned().secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.to_owned().secondary(), + ]), + ]); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap new file mode 100644 index 0000000..8a1326b --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Artist Name ▼ Source Title Quality Date +=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap new file mode 100644 index 0000000..639316b --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Artist Name ▼ Source Title Quality Date +=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭────── Clear Blocklist ──────╮ + │ Do you want to clear your │ + │ blocklist? │ + │ │ + │ │ + │ │ + │╭──────────────╮╭─────────────╮│ + ││ Yes ││ No ││ + │╰──────────────╯╰─────────────╯│ + ╰───────────────────────────────╯ diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap new file mode 100644 index 0000000..3359023 --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Artist Name ▼ Source Title Quality Date +=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Name: Alex - Something │ + │Protocol: usenet │ + │Indexer: NZBgeek (Prowlarr) │ + │Message: test message │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap new file mode 100644 index 0000000..22d3ad6 --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Artist Name Source Title Quality Date +=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Something │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap new file mode 100644 index 0000000..0b417ef --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Artist Name ▼ Source Title Quality Date +=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + ╭────────────── Remove Item from Blocklist ───────────────╮ + │ Do you want to remove this item from your blocklist: │ + │ Alex - Something? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap new file mode 100644 index 0000000..3863f67 --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap new file mode 100644 index 0000000..c681094 --- /dev/null +++ b/src/ui/lidarr_ui/blocklist/snapshots/managarr__ui__lidarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/lidarr_ui/lidarr_ui_tests.rs b/src/ui/lidarr_ui/lidarr_ui_tests.rs index 5265a47..1241729 100644 --- a/src/ui/lidarr_ui/lidarr_ui_tests.rs +++ b/src/ui/lidarr_ui/lidarr_ui_tests.rs @@ -23,9 +23,11 @@ mod tests { #[rstest] #[case(ActiveLidarrBlock::Artists, 0)] #[case(ActiveLidarrBlock::Downloads, 1)] - #[case(ActiveLidarrBlock::History, 2)] - #[case(ActiveLidarrBlock::RootFolders, 3)] - #[case(ActiveLidarrBlock::Indexers, 4)] + #[case(ActiveLidarrBlock::Blocklist, 2)] + #[case(ActiveLidarrBlock::History, 3)] + #[case(ActiveLidarrBlock::RootFolders, 4)] + #[case(ActiveLidarrBlock::Indexers, 5)] + #[case(ActiveLidarrBlock::System, 6)] fn test_lidarr_ui_renders_lidarr_tabs( #[case] active_lidarr_block: ActiveLidarrBlock, #[case] index: usize, diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index 749dd5a..a6aba0d 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -23,6 +23,7 @@ use super::{ }, widgets::loading_block::LoadingBlock, }; +use crate::ui::lidarr_ui::blocklist::BlocklistUi; use crate::ui::lidarr_ui::downloads::DownloadsUi; use crate::ui::lidarr_ui::indexers::IndexersUi; use crate::ui::lidarr_ui::root_folders::RootFoldersUi; @@ -39,6 +40,7 @@ use crate::{ utils::convert_to_gb, }; +mod blocklist; mod downloads; mod history; mod indexers; @@ -65,6 +67,7 @@ impl DrawUi for LidarrUi { match route { _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), + _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), _ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area), diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap index ce25be8..9ca7db1 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap @@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs expression: output --- ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ │=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Blocklist.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Blocklist.snap new file mode 100644 index 0000000..0dd3d49 --- /dev/null +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Blocklist.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/lidarr_ui/lidarr_ui_tests.rs +expression: output +--- +╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Artist Name ▼ Source Title Quality Date │ +│=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Downloads.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Downloads.snap index 33c20a2..297f4ac 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Downloads.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Downloads.snap @@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs expression: output --- ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Title Percent Complete Size Output Path Indexer Download Client │ │=> Test download title 50% 3.30 GB /nfs/music/alex/album kickass torrents transmission │ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_History.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_History.snap index 74dd954..032ebad 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_History.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_History.snap @@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs expression: output --- ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Title ▼ Event Type Quality Date │ │=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC │ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Indexers.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Indexers.snap index b91005e..068286c 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Indexers.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Indexers.snap @@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs expression: output --- ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Indexer RSS Automatic Search Interactive Search Priority Tags │ │=> Test Indexer Enabled Enabled Enabled 25 alex │ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_RootFolders.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_RootFolders.snap index 04ab516..65268a6 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_RootFolders.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_RootFolders.snap @@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs expression: output --- ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Path Free Space Unmapped Folders │ │=> /nfs 204800.00 GB 0 │ diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_System.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_System.snap new file mode 100644 index 0000000..e18c7c9 --- /dev/null +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_System.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/lidarr_ui/lidarr_ui_tests.rs +expression: output +--- +╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│╭ Tasks ───────────────────────────────────────────────────────────────────────╮╭ Queued Events ──────────────────────────────────────────────────────────────╮│ +││Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration ││ +││Backup 1 hour now 59 minutes ││manual completed Refresh Monitored 4 minutes ago 4 minutes a 00:03:03 ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +│╰────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────╯│ +│╭ Logs ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap index c81afcd..e7c2b01 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap @@ -16,7 +16,7 @@ expression: output │ ││ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │ ╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ │=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap index df4e759..c90b1f0 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap @@ -16,7 +16,7 @@ expression: output │ │ s search │ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │ ╰───────────────────────────────────│ f filter │─────────────────╯╰──────────────────╯ ╭ Artists ────────────────────────│ ctrl-r refresh │─────────────────────────────────────╮ -│ Library │ Downloads │ History │ Ro│ u update all │ │ +│ Library │ Downloads │ Blocklist │ │ u update all │ │ │───────────────────────────────────│ enter details │─────────────────────────────────────│ │ Name ▼ Typ│ esc cancel filter │e Monitored Tags │ │=> Alex Per│ ↑ k scroll up │0 GB 🏷 alex │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap index 7cc82ac..25b0aa2 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap @@ -19,7 +19,7 @@ expression: output │ ││ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │ ╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ ╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Library │ Downloads │ History │ Root Folders │ Indexers │ System │ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ │=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │