From 1ca9265a2a061bd70c4f0a89acf504ce96fcc0a6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 11 Nov 2024 13:45:32 -0700 Subject: [PATCH] feat(sonarr): Added blocklist commands (List, Clear, Delete) --- src/cli/cli_tests.rs | 75 ++++--- src/cli/sonarr/delete_command_handler.rs | 70 ++++++ .../sonarr/delete_command_handler_tests.rs | 111 ++++++++++ src/cli/sonarr/list_command_handler.rs | 5 + src/cli/sonarr/list_command_handler_tests.rs | 5 +- src/cli/sonarr/mod.rs | 27 ++- src/cli/sonarr/sonarr_command_tests.rs | 82 ++++++- src/models/servarr_data/sonarr/sonarr_data.rs | 10 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 39 ++++ src/models/sonarr_models_tests.rs | 22 +- src/network/radarr_network.rs | 17 +- src/network/radarr_network_tests.rs | 6 +- src/network/sonarr_network.rs | 104 +++++++++ src/network/sonarr_network_tests.rs | 203 +++++++++++++++++- 15 files changed, 725 insertions(+), 52 deletions(-) create mode 100644 src/cli/sonarr/delete_command_handler.rs create mode 100644 src/cli/sonarr/delete_command_handler_tests.rs diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index d94e97c..f58b6cd 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -10,12 +10,21 @@ mod tests { use crate::{ app::App, - cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand}, + cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, models::{ - radarr_models::{BlocklistItem, BlocklistResponse, RadarrSerdeable}, + radarr_models::{ + BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, + RadarrSerdeable, + }, + sonarr_models::{ + BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, + SonarrSerdeable, + }, Serdeable, }, - network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, + network::{ + radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent, + }, Cli, }; use pretty_assertions::assert_eq; @@ -113,8 +122,8 @@ mod tests { .times(1) .returning(|_| { Ok(Serdeable::Radarr(RadarrSerdeable::BlocklistResponse( - BlocklistResponse { - records: vec![BlocklistItem::default()], + RadarrBlocklistResponse { + records: vec![RadarrBlocklistItem::default()], }, ))) }); @@ -135,34 +144,34 @@ mod tests { assert!(result.is_ok()); } - // TODO: Uncomment to properly test delegation once the ClearBlocklist command is added to Sonarr - // #[tokio::test] - // async fn test_cli_handler_delegates_sonarr_commands_to_the_sonarr_cli_handler() { - // let mut mock_network = MockNetworkTrait::new(); - // mock_network - // .expect_handle_network_event() - // .with(eq::(SonarrEvent::GetBlocklist.into())) - // .times(1) - // .returning(|_| { - // Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse( - // BlocklistResponse { - // records: vec![BlocklistItem::default()], - // }, - // ))) - // }); - // mock_network - // .expect_handle_network_event() - // .with(eq::(SonarrEvent::ClearBlocklist.into())) - // .times(1) - // .returning(|_| { - // Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - // json!({"testResponse": "response"}), - // ))) - // }); - // let clear_blocklist_command = SonarrCommand::ClearBlocklist.into(); + #[tokio::test] + async fn test_cli_handler_delegates_sonarr_commands_to_the_sonarr_cli_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse( + SonarrBlocklistResponse { + records: vec![SonarrBlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let clear_blocklist_command = SonarrCommand::ClearBlocklist.into(); - // let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; - // assert!(result.is_ok()); - // } + assert!(result.is_ok()); + } } diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs new file mode 100644 index 0000000..cc73e74 --- /dev/null +++ b/src/cli/sonarr/delete_command_handler.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + execute_network_event, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrDeleteCommand { + #[command(about = "Delete the specified item from the Sonarr blocklist")] + BlocklistItem { + #[arg( + long, + help = "The ID of the blocklist item to remove from the blocklist", + required = true + )] + blocklist_item_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrDeleteCommand) -> Self { + Command::Sonarr(SonarrCommand::Delete(value)) + } +} + +pub(super) struct SonarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrDeleteCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result<()> { + match self.command { + SonarrDeleteCommand::BlocklistItem { blocklist_item_id } => { + execute_network_event!( + self, + SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)) + ); + } + } + + Ok(()) + } +} diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs new file mode 100644 index 0000000..546f433 --- /dev/null +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -0,0 +1,111 @@ +#[cfg(test)] +mod tests { + use crate::{ + cli::{ + sonarr::{delete_command_handler::SonarrDeleteCommand, SonarrCommand}, + Command, + }, + Cli, + }; + use clap::{error::ErrorKind, CommandFactory, Parser}; + + #[test] + fn test_sonarr_delete_command_from() { + let command = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_blocklist_item_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "blocklist-item"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_blocklist_item_success() { + let expected_args = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[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::( + SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_blocklist_item_command = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = SonarrDeleteCommandHandler::with( + &app_arc, + delete_blocklist_item_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 6ca0d51..b9cb31b 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -19,6 +19,8 @@ mod list_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrListCommand { + #[command(about = "List all items in the Sonarr blocklist")] + Blocklist, #[command(about = "List all series in your Sonarr library")] Series, } @@ -50,6 +52,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH async fn handle(self) -> Result<()> { match self.command { + SonarrListCommand::Blocklist => { + execute_network_event!(self, SonarrEvent::GetBlocklist); + } SonarrListCommand::Series => { execute_network_event!(self, SonarrEvent::ListSeries); } diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 52d6819..f505199 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -21,7 +21,9 @@ mod tests { use rstest::rstest; #[rstest] - fn test_list_commands_have_no_arg_requirements(#[values("series")] subcommand: &str) { + fn test_list_commands_have_no_arg_requirements( + #[values("blocklist", "series")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); assert!(result.is_ok()); @@ -47,6 +49,7 @@ mod tests { }; #[rstest] + #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[tokio::test] async fn test_handle_list_command( diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 6c3b9e0..fdb03e2 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -2,14 +2,20 @@ use std::sync::Arc; use anyhow::Result; use clap::Subcommand; +use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use tokio::sync::Mutex; -use crate::{app::App, network::NetworkTrait}; +use crate::{ + app::App, + execute_network_event, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; use super::{CliCommandHandler, Command}; +mod delete_command_handler; mod get_command_handler; mod list_command_handler; @@ -19,6 +25,11 @@ mod sonarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrCommand { + #[command( + subcommand, + about = "Commands to delete resources from your Sonarr instance" + )] + Delete(SonarrDeleteCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Sonarr instance" @@ -29,6 +40,8 @@ pub enum SonarrCommand { about = "Commands to list attributes from your Sonarr instance" )] List(SonarrListCommand), + #[command(about = "Clear the blocklist")] + ClearBlocklist, } impl From for Command { @@ -58,6 +71,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' async fn handle(self) -> Result<()> { match self.command { + SonarrCommand::Delete(delete_command) => { + SonarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } SonarrCommand::Get(get_command) => { SonarrGetCommandHandler::with(self.app, get_command, self.network) .handle() @@ -68,6 +86,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } + SonarrCommand::ClearBlocklist => { + self + .network + .handle_network_event(SonarrEvent::GetBlocklist.into()) + .await?; + execute_network_event!(self, SonarrEvent::ClearBlocklist); + } } Ok(()) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index a1840be..4d1d4f6 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -4,6 +4,8 @@ mod tests { sonarr::{list_command_handler::SonarrListCommand, SonarrCommand}, Command, }; + use crate::Cli; + use clap::CommandFactory; #[test] fn test_sonarr_command_from() { @@ -14,7 +16,17 @@ mod tests { assert_eq!(result, Command::Sonarr(command)); } - mod cli {} + mod cli { + use super::*; + use rstest::rstest; + + #[rstest] + fn test_commands_that_have_no_arg_requirements(#[values("clear-blocklist")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", subcommand]); + + assert!(result.is_ok()); + } + } mod handler { use std::sync::Arc; @@ -27,18 +39,80 @@ mod tests { app::App, cli::{ sonarr::{ - get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, - SonarrCliHandler, SonarrCommand, + delete_command_handler::SonarrDeleteCommand, get_command_handler::SonarrGetCommand, + list_command_handler::SonarrListCommand, SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, models::{ - sonarr_models::{Series, SonarrSerdeable}, + sonarr_models::{BlocklistItem, BlocklistResponse, Series, SonarrSerdeable}, Serdeable, }, network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_clear_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse( + BlocklistResponse { + records: vec![BlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let claer_blocklist_command = SonarrCommand::ClearBlocklist; + + let result = SonarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() { + let expected_blocklist_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_blocklist_item_command = + SonarrCommand::Delete(SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, delete_blocklist_item_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_sonarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index d43f669..275e0ff 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -1,7 +1,11 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; -use crate::models::{sonarr_models::Series, stateful_table::StatefulTable, Route}; +use crate::models::{ + sonarr_models::{BlocklistItem, Series}, + stateful_table::StatefulTable, + Route, +}; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -11,6 +15,7 @@ pub struct SonarrData { pub version: String, pub start_time: DateTime, pub series: StatefulTable, + pub blocklist: StatefulTable, } impl Default for SonarrData { @@ -19,12 +24,15 @@ impl Default for SonarrData { version: String::new(), start_time: DateTime::default(), series: StatefulTable::default(), + blocklist: StatefulTable::default(), } } } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveSonarrBlock { + Blocklist, + BlocklistSortPrompt, #[default] Series, SeriesSortPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index cc29b79..6b9c590 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -37,6 +37,7 @@ mod tests { assert!(sonarr_data.version.is_empty()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.series.is_empty()); + assert!(sonarr_data.blocklist.is_empty()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 3b878eb..32f4563 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -15,6 +15,43 @@ use super::{HorizontallyScrollableText, Serdeable}; #[path = "sonarr_models_tests.rs"] mod sonarr_models_tests; +#[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 series_id: i64, + pub episode_ids: Vec, + pub source_title: String, + pub language: Language, + pub quality: QualityWrapper, + pub date: DateTime, + pub protocol: String, + pub indexer: String, + pub message: String, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct BlocklistResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Language { + pub name: String, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Quality { + pub name: String, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct QualityWrapper { + pub quality: Quality, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] #[derivative(Default)] pub struct Rating { @@ -177,6 +214,7 @@ pub enum SonarrSerdeable { Value(Value), SeriesVec(Vec), SystemStatus(SystemStatus), + BlocklistResponse(BlocklistResponse), } impl From for Serdeable { @@ -196,6 +234,7 @@ serde_enum_from!( Value(Value), SeriesVec(Vec), SystemStatus(SystemStatus), + BlocklistResponse(BlocklistResponse), } ); diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 15387f6..cd47089 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,7 +4,10 @@ mod tests { use serde_json::json; use crate::models::{ - sonarr_models::{Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus}, + sonarr_models::{ + BlocklistItem, BlocklistResponse, Series, SeriesStatus, SeriesType, SonarrSerdeable, + SystemStatus, + }, Serdeable, }; @@ -89,4 +92,21 @@ mod tests { SonarrSerdeable::SystemStatus(system_status) ); } + + #[test] + fn test_sonarr_serdeable_from_blocklist_response() { + let blocklist_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 1, + ..BlocklistItem::default() + }], + }; + + let sonarr_serdeable: SonarrSerdeable = blocklist_response.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::BlocklistResponse(blocklist_response) + ); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 2c13156..979e52b 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -145,9 +145,12 @@ impl<'a, 'b> Network<'a, 'b> { self.add_root_folder(path).await.map(RadarrSerdeable::from) } RadarrEvent::AddTag(tag) => self.add_tag(tag).await.map(RadarrSerdeable::from), - RadarrEvent::ClearBlocklist => self.clear_blocklist().await.map(RadarrSerdeable::from), + RadarrEvent::ClearBlocklist => self + .clear_radarr_blocklist() + .await + .map(RadarrSerdeable::from), RadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self - .delete_blocklist_item(blocklist_item_id) + .delete_radarr_blocklist_item(blocklist_item_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteDownload(download_id) => self @@ -186,7 +189,7 @@ impl<'a, 'b> Network<'a, 'b> { .get_all_indexer_settings() .await .map(RadarrSerdeable::from), - RadarrEvent::GetBlocklist => self.get_blocklist().await.map(RadarrSerdeable::from), + RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), RadarrEvent::GetDownloads => self.get_downloads().await.map(RadarrSerdeable::from), RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), @@ -421,7 +424,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn clear_blocklist(&mut self) -> Result<()> { + async fn clear_radarr_blocklist(&mut self) -> Result<()> { info!("Clearing Radarr blocklist"); let event = RadarrEvent::ClearBlocklist; @@ -452,7 +455,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { + async fn delete_radarr_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { let event = RadarrEvent::DeleteBlocklistItem(None); let id = if let Some(b_id) = blocklist_item_id { b_id @@ -1244,8 +1247,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_blocklist(&mut self) -> Result { - info!("Fetching blocklist"); + async fn get_radarr_blocklist(&mut self) -> Result { + info!("Fetching Radarr blocklist"); let event = RadarrEvent::GetBlocklist; let request_props = self diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index d1dfff6..97a7781 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -1679,7 +1679,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_handle_get_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + async fn test_handle_get_radarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { let blocklist_json = json!({"records": [{ "id": 123, "movieId": 1007, @@ -3134,7 +3134,7 @@ mod test { } #[tokio::test] - async fn test_handle_clear_blocklist_event() { + async fn test_handle_clear_radarr_blocklist_event() { let blocklist_items = vec![ BlocklistItem { id: 1, @@ -3178,7 +3178,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_blocklist_item_event() { + async fn test_handle_delete_radarr_blocklist_item_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, None, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index f155763..2ad14be 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,9 +1,11 @@ use anyhow::Result; use log::info; +use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + sonarr_models::BlocklistResponse, sonarr_models::{Series, SonarrSerdeable, SystemStatus}, Route, }, @@ -17,6 +19,9 @@ mod sonarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum SonarrEvent { + ClearBlocklist, + DeleteBlocklistItem(Option), + GetBlocklist, GetStatus, HealthCheck, ListSeries, @@ -25,6 +30,9 @@ pub enum SonarrEvent { impl NetworkResource for SonarrEvent { fn resource(&self) -> &'static str { match &self { + SonarrEvent::ClearBlocklist => "/blocklist/bulk", + SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", + SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -44,6 +52,15 @@ impl<'a, 'b> Network<'a, 'b> { sonarr_event: SonarrEvent, ) -> Result { match sonarr_event { + SonarrEvent::ClearBlocklist => self + .clear_sonarr_blocklist() + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_sonarr_blocklist_item(blocklist_item_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -53,6 +70,70 @@ impl<'a, 'b> Network<'a, 'b> { } } + async fn clear_sonarr_blocklist(&mut self) -> Result<()> { + info!("Clearing Sonarr blocklist"); + let event = SonarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .sonarr_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 + } + + async fn delete_sonarr_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteBlocklistItem(None); + let id = if let Some(b_id) = blocklist_item_id { + b_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .blocklist + .current_selection() + .id + }; + + info!("Deleting Sonarr blocklist item for item with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + async fn get_sonarr_healthcheck(&mut self) -> Result<()> { info!("Performing Sonarr health check"); let event = SonarrEvent::HealthCheck; @@ -66,6 +147,29 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_blocklist(&mut self) -> Result { + info!("Fetching Sonarr blocklist"); + let event = SonarrEvent::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::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec = blocklist_resp.records; + blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.blocklist.set_items(blocklist_vec); + app.data.sonarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } + async fn list_series(&mut self) -> Result> { info!("Fetching Sonarr library"); let event = SonarrEvent::ListSeries; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 0de2bb0..538fa85 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -9,7 +9,9 @@ mod test { use tokio_util::sync::CancellationToken; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::SystemStatus; + use crate::models::sonarr_models::{BlocklistItem, Language}; + use crate::models::sonarr_models::{BlocklistResponse, Quality}; + use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; use crate::{ @@ -73,8 +75,11 @@ mod test { } #[rstest] + #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetStatus, "/system/status")] + #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -87,6 +92,171 @@ mod test { ); } + #[tokio::test] + async fn test_handle_clear_radarr_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 (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + Some(expected_request_json), + None, + None, + SonarrEvent::ClearBlocklist, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(blocklist_items); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ClearBlocklist) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_blocklist_item_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(vec![blocklist_item()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteBlocklistItem(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + let blocklist_json = json!({"records": [{ + "seriesId": 1007, + "episodeIds": [42020], + "sourceTitle": "z series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123 + }, + { + "seriesId": 2001, + "episodeIds": [42018], + "sourceTitle": "A Series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456 + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let mut expected_blocklist = vec![ + BlocklistItem { + id: 123, + series_id: 1007, + source_title: "z series".into(), + episode_ids: vec![Number::from(42020)], + ..blocklist_item() + }, + BlocklistItem { + id: 456, + series_id: 2001, + source_title: "A Series".into(), + episode_ids: vec![Number::from(42018)], + ..blocklist_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + SonarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_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_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::BlocklistResponse(blocklist) = network + .handle_sonarr_event(SonarrEvent::GetBlocklist) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.blocklist.items, + expected_blocklist + ); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_healthcheck_event() { let (async_server, app_arc, _server) = mock_servarr_api( @@ -283,6 +453,37 @@ mod test { } } + fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + series_id: 1, + episode_ids: vec![Number::from(1)], + source_title: "Test Source Title".to_owned(), + language: language(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "NZBgeek (Prowlarr)".to_owned(), + message: "test message".to_owned(), + } + } + + fn language() -> Language { + Language { + name: "English".to_owned(), + } + } + + fn quality() -> Quality { + Quality { + name: "Bluray-1080p".to_owned(), + } + } + + fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + fn rating() -> Rating { Rating { votes: 406744,