feat: Blocklist support in Lidarr in both the CLI and TUI

This commit is contained in:
2026-01-19 16:13:11 -07:00
parent eff1a901eb
commit 89f5ff6bc7
48 changed files with 2211 additions and 66 deletions
+18
View File
@@ -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::<NetworkEvent>(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::<NetworkEvent>(500);
+5
View File
@@ -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())
+36 -1
View File
@@ -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::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
LidarrBlocklistResponse {
records: vec![LidarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(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);
}
}
+16
View File
@@ -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
@@ -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::<NetworkEvent>(
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;
+37 -2
View File
@@ -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::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(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 {
+9
View File
@@ -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
@@ -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)]
+13
View File
@@ -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
@@ -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<BlocklistItem> {
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()
},
]
}
}
@@ -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<ActiveLidarrBlock>,
}
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<ActiveLidarrBlock>,
) -> 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<SortOption<BlocklistItem>> {
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)),
},
]
}
@@ -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]
@@ -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,
@@ -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,
@@ -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(
+5
View File
@@ -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();
}
@@ -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,
@@ -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,
+23
View File
@@ -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<Vec<Number>>,
pub source_title: String,
pub quality: QualityWrapper,
pub date: DateTime<Utc>,
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<BlocklistItem>,
}
#[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<Artist>),
BlocklistResponse(BlocklistResponse),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
LidarrHistoryWrapper(LidarrHistoryWrapper),
+21 -4
View File
@@ -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 {
+26 -2
View File
@@ -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<LidarrHistoryItem>,
pub artist_info_tabs: TabState,
pub artists: StatefulTable<Artist>,
pub blocklist: StatefulTable<BlocklistItem>,
pub delete_files: bool,
pub discography_releases: StatefulTable<LidarrRelease>,
pub disk_space_vec: Vec<DiskSpace>,
@@ -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,
@@ -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);
@@ -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);
}
}
@@ -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::<Vec<i64>>();
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
Some(json!({"ids": ids})),
None,
None,
)
.await;
self
.handle_request::<Value, ()>(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<BlocklistResponse> {
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<BlocklistItem> = 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
}
}
@@ -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()],
}
}
}
@@ -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")]
+16
View File
@@ -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
@@ -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);
}
}
}
+156
View File
@@ -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());
}
@@ -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
@@ -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 ││
│╰──────────────╯╰─────────────╯│
╰───────────────────────────────╯
@@ -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 │
│ │
│ │
│ │
│ │
╰─────────────────────────────────────────────────────────────────────────────────╯
@@ -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 │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────╯
@@ -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 ││
│╰────────────────────────────╯╰───────────────────────────╯│
╰───────────────────────────────────────────────────────────╯
@@ -0,0 +1,5 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,8 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Loading ...
+5 -3
View File
@@ -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,
+3
View File
@@ -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),
@@ -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 │
@@ -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 │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -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 │
@@ -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 │
@@ -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 │
@@ -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 │
@@ -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 ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -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 │
@@ -16,7 +16,7 @@ expression: output
│ │ s search │ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │
╰───────────────────────────────────│ f filter │─────────────────╯╰──────────────────╯
╭ Artists ────────────────────────│ ctrl-r refresh │─────────────────────────────────────╮
│ Library │ Downloads │ HistoryRo│ 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 │
@@ -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 │